diff --git a/Fixtures/Miscellaneous/TestDebugging/Package.swift b/Fixtures/Miscellaneous/TestDebugging/Package.swift new file mode 100644 index 00000000000..96687b0b207 --- /dev/null +++ b/Fixtures/Miscellaneous/TestDebugging/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "TestDebugging", + targets: [ + .target(name: "TestDebugging"), + .testTarget(name: "TestDebuggingTests", dependencies: ["TestDebugging"]), + ] +) \ No newline at end of file diff --git a/Fixtures/Miscellaneous/TestDebugging/Sources/TestDebugging/TestDebugging.swift b/Fixtures/Miscellaneous/TestDebugging/Sources/TestDebugging/TestDebugging.swift new file mode 100644 index 00000000000..8a3b22fdebd --- /dev/null +++ b/Fixtures/Miscellaneous/TestDebugging/Sources/TestDebugging/TestDebugging.swift @@ -0,0 +1,23 @@ +public struct Calculator { + public init() {} + + public func add(_ a: Int, _ b: Int) -> Int { + return a + b + } + + public func subtract(_ a: Int, _ b: Int) -> Int { + return a - b + } + + public func multiply(_ a: Int, _ b: Int) -> Int { + return a * b + } + + public func divide(_ a: Int, _ b: Int) -> Int { + return a / b + } + + public func purposelyFail() -> Bool { + return false + } +} \ No newline at end of file diff --git a/Fixtures/Miscellaneous/TestDebugging/Tests/TestDebuggingTests/TestDebuggingTests.swift b/Fixtures/Miscellaneous/TestDebugging/Tests/TestDebuggingTests/TestDebuggingTests.swift new file mode 100644 index 00000000000..c5b3aca2fd3 --- /dev/null +++ b/Fixtures/Miscellaneous/TestDebugging/Tests/TestDebuggingTests/TestDebuggingTests.swift @@ -0,0 +1,34 @@ +import XCTest +import Testing +@testable import TestDebugging + +// MARK: - XCTest Suite +final class XCTestCalculatorTests: XCTestCase { + + func testAdditionPasses() { + let calculator = Calculator() + let result = calculator.add(2, 3) + XCTAssertEqual(result, 5, "Addition should return 5 for 2 + 3") + } + + func testSubtractionFails() { + let calculator = Calculator() + let result = calculator.subtract(5, 3) + XCTAssertEqual(result, 3, "This test is designed to fail - subtraction 5 - 3 should equal 2, not 3") + } +} + +// MARK: - Swift Testing Suite +@Test("Calculator Addition Works Correctly") +func calculatorAdditionPasses() { + let calculator = Calculator() + let result = calculator.add(4, 6) + #expect(result == 10, "Addition should return 10 for 4 + 6") +} + +@Test("Calculator Boolean Check Fails") +func calculatorBooleanFails() { + let calculator = Calculator() + let result = calculator.purposelyFail() + #expect(result == true, "This test is designed to fail - purposelyFail() should return false, not true") +} \ No newline at end of file diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 669f04dab3b..279b283bb88 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -216,6 +216,11 @@ struct TestCommandOptions: ParsableArguments { help: "Enable code coverage.") var enableCodeCoverage: Bool = false + /// Launch tests under LLDB debugger. + @Flag(name: .customLong("debugger"), + help: "Launch the tests in a debugger session.") + var shouldLaunchInLLDB: Bool = false + /// Configure test output. @Option(help: ArgumentHelp("", visibility: .hidden)) public var testOutput: TestOutput = .default @@ -280,8 +285,17 @@ public struct SwiftTestCommand: AsyncSwiftCommand { var results = [TestRunner.Result]() + if options.shouldLaunchInLLDB { + let result = try await runTestProductsWithLLDB( + testProducts, + productsBuildParameters: buildParameters, + swiftCommandState: swiftCommandState + ) + results.append(result) + } + // Run XCTest. - if options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) { + if !options.shouldLaunchInLLDB && options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) { // Validate XCTest is available on Darwin-based systems. If it's not available and we're hitting this code // path, that means the developer must have explicitly passed --enable-xctest (or the toolchain is // corrupt, I suppose.) @@ -351,7 +365,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand { } // Run Swift Testing (parallel or not, it has a single entry point.) - if options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) { + if !options.shouldLaunchInLLDB && options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) { lazy var testEntryPointPath = testProducts.lazy.compactMap(\.testEntryPointPath).first if options.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || testEntryPointPath == nil { results.append( @@ -474,6 +488,182 @@ public struct SwiftTestCommand: AsyncSwiftCommand { } } + /// Runs test products under LLDB debugger for interactive debugging. + /// + /// This method handles debugging for enabled testing libraries: + /// 1. If both XCTest and Swift Testing are enabled, prompts user to choose or runs both in separate sessions + /// 2. Validates that exactly one test product is available for debugging + /// 3. Creates a DebugTestRunner and launches LLDB with the test binary + /// + /// - Parameters: + /// - testProducts: The built test products + /// - productsBuildParameters: Build parameters for the products + /// - swiftCommandState: The Swift command state + /// - Returns: The test result (typically .success since LLDB takes over) + private func runTestProductsWithLLDB( + _ testProducts: [BuiltTestProduct], + productsBuildParameters: BuildParameters, + swiftCommandState: SwiftCommandState + ) async throws -> TestRunner.Result { + // Validate that we have exactly one test product for debugging + guard testProducts.count == 1 else { + if testProducts.isEmpty { + throw StringError("No test products found for debugging") + } else { + let productNames = testProducts.map { $0.productName }.joined(separator: ", ") + throw StringError("Multiple test products found (\(productNames)). Specify a single target with --filter when using --debugger") + } + } + + let testProduct = testProducts[0] + let toolchain = try swiftCommandState.getTargetToolchain() + + // Determine which testing libraries are enabled + let xctestEnabled = options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) + let swiftTestingEnabled = options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) && + (options.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + testProduct.testEntryPointPath == nil) + + // Create a list of testing libraries to run in sequence, checking for actual tests + var librariesToRun: [TestingLibrary] = [] + var skippedLibraries: [(TestingLibrary, String)] = [] + + // Only add XCTest if it's enabled AND has tests to run + if xctestEnabled { + // Always check for XCTest tests by getting test suites + let testSuites = try TestingSupport.getTestSuites( + in: testProducts, + swiftCommandState: swiftCommandState, + enableCodeCoverage: options.enableCodeCoverage, + shouldSkipBuilding: options.sharedOptions.shouldSkipBuilding, + experimentalTestOutput: options.enableExperimentalTestOutput, + sanitizers: globalOptions.build.sanitizers + ) + let filteredTests = try testSuites + .filteredTests(specifier: options.testCaseSpecifier) + .skippedTests(specifier: options.skippedTests(fileSystem: swiftCommandState.fileSystem)) + + if !filteredTests.isEmpty { + librariesToRun.append(.xctest) + } else { + skippedLibraries.append((.xctest, "no XCTest tests found")) + } + } + + if swiftTestingEnabled { + librariesToRun.append(.swiftTesting) + } + + // Ensure we have at least one library to run + guard !librariesToRun.isEmpty else { + if !skippedLibraries.isEmpty { + let skippedMessages = skippedLibraries.map { library, reason in + let libraryName = library == .xctest ? "XCTest" : "Swift Testing" + return "\(libraryName): \(reason)" + } + throw StringError("No testing libraries have tests to debug. Skipped: \(skippedMessages.joined(separator: ", "))") + } + throw StringError("No testing libraries are enabled for debugging") + } + + try await runTestLibrariesWithLLDB( + testProduct: testProduct, + target: DebuggableTestSession( + targets: librariesToRun.map { + DebuggableTestSession.Target( + library: $0, + additionalArgs: try additionalLLDBArguments(for: $0, testProducts: testProducts, swiftCommandState: swiftCommandState), + bundlePath: testBundlePath(for: $0, testProduct: testProduct) + ) + } + ), + testProducts: testProducts, + productsBuildParameters: productsBuildParameters, + swiftCommandState: swiftCommandState, + toolchain: toolchain + ) + + // Clean up Python script file after all sessions complete + // (Breakpoint file cleanup is handled by DebugTestRunner based on SessionState) + if librariesToRun.count > 1 { + let tempDir = try swiftCommandState.fileSystem.tempDirectory + let pythonScriptFile = tempDir.appending("save_breakpoints.py") + + if swiftCommandState.fileSystem.exists(pythonScriptFile) { + try? swiftCommandState.fileSystem.removeFileTree(pythonScriptFile) + } + } + + return .success + } + + private func additionalLLDBArguments(for library: TestingLibrary, testProducts: [BuiltTestProduct], swiftCommandState: SwiftCommandState) throws -> [String] { + // Determine test binary path and arguments based on the testing library + switch library { + case .xctest: + let (xctestArgs, _) = try xctestArgs(for: testProducts, swiftCommandState: swiftCommandState) + return xctestArgs + + case .swiftTesting: + let commandLineArguments = CommandLine.arguments.dropFirst() + var swiftTestingArgs = ["--testing-library", "swift-testing", "--enable-swift-testing"] + + if let separatorIndex = commandLineArguments.firstIndex(of: "--") { + // Only pass arguments after the "--" separator + swiftTestingArgs += Array(commandLineArguments.dropFirst(separatorIndex + 1)) + } + return swiftTestingArgs + } + } + + private func testBundlePath(for library: TestingLibrary, testProduct: BuiltTestProduct) -> AbsolutePath { + switch library { + case .xctest: + testProduct.bundlePath + case .swiftTesting: + testProduct.binaryPath + } + } + + /// Runs a single testing library under LLDB debugger. + /// + /// - Parameters: + /// - testProduct: The test product to debug + /// - library: The testing library to run + /// - testProducts: All built test products (for XCTest args generation) + /// - productsBuildParameters: Build parameters for the products + /// - swiftCommandState: The Swift command state + /// - toolchain: The toolchain to use + /// - sessionState: The debugging session state for breakpoint persistence + private func runTestLibrariesWithLLDB( + testProduct: BuiltTestProduct, + target: DebuggableTestSession, + testProducts: [BuiltTestProduct], + productsBuildParameters: BuildParameters, + swiftCommandState: SwiftCommandState, + toolchain: UserToolchain + ) async throws { + // Create and launch the debug test runner + let debugRunner = DebugTestRunner( + target: target, + buildParameters: productsBuildParameters, + toolchain: toolchain, + testEnv: try TestingSupport.constructTestEnvironment( + toolchain: toolchain, + destinationBuildParameters: productsBuildParameters, + sanitizers: globalOptions.build.sanitizers, + library: .xctest // TODO + ), + cancellator: swiftCommandState.cancellator, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope, + verbose: globalOptions.logging.verbose + ) + + // Launch LLDB using AsyncProcess with proper input/output forwarding + try debugRunner.run() + } + private func runTestProducts( _ testProducts: [BuiltTestProduct], additionalArguments: [String], @@ -667,6 +857,11 @@ public struct SwiftTestCommand: AsyncSwiftCommand { /// /// - Throws: if a command argument is invalid private func validateArguments(swiftCommandState: SwiftCommandState) throws { + // Validation for --debugger first, since it affects other validations. + if options.shouldLaunchInLLDB { + try validateLLDBCompatibility(swiftCommandState: swiftCommandState) + } + // Validation for --num-workers. if let workers = options.numberOfWorkers { // The --num-worker option should be called with --parallel. Since @@ -690,6 +885,36 @@ public struct SwiftTestCommand: AsyncSwiftCommand { } } + /// Validates that --debugger is compatible with other provided arguments + /// + /// - Throws: if --debugger is used with incompatible flags + private func validateLLDBCompatibility(swiftCommandState: SwiftCommandState) throws { + // --debugger cannot be used with release configuration + let configuration = options.globalOptions.build.configuration ?? swiftCommandState.preferredBuildConfiguration + if configuration == .release { + throw StringError("--debugger cannot be used with release configuration (debugging requires debug symbols)") + } + + // --debugger cannot be used with parallel testing + if options.shouldRunInParallel { + throw StringError("--debugger cannot be used with --parallel (debugging requires sequential execution)") + } + + // --debugger cannot be used with --num-workers (which requires --parallel anyway) + if options.numberOfWorkers != nil { + throw StringError("--debugger cannot be used with --num-workers (debugging requires sequential execution)") + } + + // --debugger cannot be used with information-only modes that exit early + if options._deprecated_shouldListTests { + throw StringError("--debugger cannot be used with --list-tests (use 'swift test list' for listing tests)") + } + + if options.shouldPrintCodeCovPath { + throw StringError("--debugger cannot be used with --show-codecov-path (debugging session cannot show paths)") + } + } + public init() {} } diff --git a/Sources/Commands/Utilities/TestingSupport.swift b/Sources/Commands/Utilities/TestingSupport.swift index 81b32961376..85a18f7374e 100644 --- a/Sources/Commands/Utilities/TestingSupport.swift +++ b/Sources/Commands/Utilities/TestingSupport.swift @@ -12,16 +12,41 @@ import Basics import CoreCommands +import Foundation import PackageModel import SPMBuildCore import TSCUtility import Workspace +#if canImport(WinSDK) +import WinSDK +#elseif canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + import struct TSCBasic.FileSystemError import class Basics.AsyncProcess import var TSCBasic.stderrStream import var TSCBasic.stdoutStream import func TSCBasic.withTemporaryFile +import func TSCBasic.exec + +struct DebuggableTestSession { + struct Target { + let library: TestingLibrary + let additionalArgs: [String] + let bundlePath: AbsolutePath + } + + let targets: [Target] + + /// Whether this is part of a multi-session sequence + var isMultiSession: Bool { + targets.count > 1 + } +} /// Internal helper functionality for the SwiftTestTool command and for the /// plugin support. @@ -276,6 +301,537 @@ enum TestingSupport { } } +/// A class to run tests under LLDB debugger. +final class DebugTestRunner { + private let target: DebuggableTestSession + private let buildParameters: BuildParameters + private let toolchain: UserToolchain + private let testEnv: Environment + private let cancellator: Cancellator + private let fileSystem: FileSystem + private let observabilityScope: ObservabilityScope + private let verbose: Bool + + /// Creates an instance of debug test runner. + init( + target: DebuggableTestSession, + buildParameters: BuildParameters, + toolchain: UserToolchain, + testEnv: Environment, + cancellator: Cancellator, + fileSystem: FileSystem, + observabilityScope: ObservabilityScope, + verbose: Bool = false + ) { + self.target = target + self.buildParameters = buildParameters + self.toolchain = toolchain + self.testEnv = testEnv + self.cancellator = cancellator + self.fileSystem = fileSystem + self.observabilityScope = observabilityScope + self.verbose = verbose + } + + /// Launches the test binary under LLDB for interactive debugging. + /// + /// This method: + /// 1. Discovers LLDB using the toolchain + /// 2. Configures the environment for debugging + /// 3. Launches LLDB with the proper test runner as target + /// 4. Provides interactive debugging experience through appropriate process management + /// + /// **Implementation approach varies by testing library:** + /// - **XCTest**: Uses PTY (pseudo-terminal) via `runInPty()` to support LLDB's full-screen + /// terminal features while maintaining parent process control for sequential execution + /// - **Swift Testing**: Uses `exec()` to replace the current process (works because Swift Testing + /// is always the last library in the sequence, avoiding the need for sequential execution) + /// + /// The PTY approach is necessary for XCTest because LLDB requires advanced terminal features + /// (ANSI escape sequences, raw input mode, terminal sizing) that simple stdin/stdout redirection + /// cannot provide, while still allowing the parent process to show completion messages and + /// run multiple testing libraries sequentially. + /// + /// **Test Mode**: When running Swift Package Manager's own tests (detected via environment variables), + /// this method uses `AsyncProcess` instead of `exec()` to launch LLDB as a subprocess without stdin. + /// This allows the parent test process to capture LLDB's output for validation while ensuring LLDB + /// exits immediately due to lack of interactive input. + /// + /// - Throws: Various errors if LLDB cannot be found or launched + func run() throws { + let lldbPath: AbsolutePath + do { + lldbPath = try toolchain.getLLDB() + } catch { + observabilityScope.emit(error: "LLDB not found in toolchain: \(error)") + throw error + } + + let lldbArgs = try prepareLLDBArguments(for: target) + observabilityScope.emit(info: "LLDB will run: \(lldbPath.pathString) \(lldbArgs.joined(separator: " "))") + + // Set environment variables from testEnv on the current process + // so they are inherited by the exec'd LLDB process. Exec will replace + // this process. + for (key, value) in testEnv { + try Environment.set(key: key, value: value) + } + + // Check if we're running Swift Package Manager's own tests + let isRunningTests = Environment.current["SWIFTPM_TESTS_LLDB"] != nil + + if isRunningTests { + // When running tests, use AsyncProcess to launch LLDB as a subprocess + // This allows the test to capture output while LLDB exits due to no stdin + try runLLDBForTesting(lldbPath: lldbPath, args: lldbArgs) + } else { + // Normal interactive mode - use exec to replace the current process with LLDB + // This avoids PTY issues that interfere with LLDB's command line editing + try exec(path: lldbPath.pathString, args: [lldbPath.pathString] + lldbArgs) + } + } + + /// Launches LLDB as a subprocess for testing purposes. + /// + /// This method is used when running Swift Package Manager's own tests to validate + /// debugger functionality. It launches LLDB without stdin attached, which causes + /// LLDB to execute its startup commands and then exit, allowing the test to capture + /// and validate the output. + /// + /// - Parameters: + /// - lldbPath: Path to the LLDB executable + /// - args: Command line arguments for LLDB + /// - Throws: Process execution errors + private func runLLDBForTesting(lldbPath: AbsolutePath, args: [String]) throws { + let process = AsyncProcess( + arguments: [lldbPath.pathString] + args, + environment: testEnv, + outputRedirection: .collect + ) + + try process.launch() + let result = try process.waitUntilExit() + + // Print the output so tests can capture it + if let stdout = try? result.utf8Output() { + print(stdout, terminator: "") + } + if let stderr = try? result.utf8stderrOutput() { + print(stderr, terminator: "") + } + + // Exit with the same code as LLDB to indicate success/failure + switch result.exitStatus { + case .terminated(let code): + if code != 0 { + throw AsyncProcessResult.Error.nonZeroExit(result) + } + default: + throw AsyncProcessResult.Error.nonZeroExit(result) + } + } + + /// Returns the path to the Python script file. + private func pythonScriptFilePath() throws -> AbsolutePath { + let tempDir = try fileSystem.tempDirectory + return tempDir.appending("target_switcher.py") + } + + /// Prepares LLDB arguments for debugging based on the testing library. + /// + /// This method creates a temporary LLDB command file with the necessary setup commands + /// for debugging tests, including target creation, argument configuration, and symbol loading. + /// + /// - Parameter library: The testing library being used (XCTest or Swift Testing) + /// - Returns: Array of LLDB command line arguments + /// - Throws: Various errors if required tools are not found or file operations fail + private func prepareLLDBArguments(for target: DebuggableTestSession) throws -> [String] { + let tempDir = try fileSystem.tempDirectory + let lldbCommandFile = tempDir.appending("lldb-commands.txt") + + var lldbCommands: [String] = [] + if target.isMultiSession { + try setupMultipleTargets(&lldbCommands) + } else if let library = target.targets.first { + try setupSingleTarget(&lldbCommands, for: library) + } else { + throw StringError("No testing libraries found for debugging") + } + + // Clear the screen of all the previous commands to unclutter the users initial state. + // Skip clearing in verbose mode so startup commands remain visible + if !verbose { + lldbCommands.append("script print(\"\\033[H\\033[J\", end=\"\")") + } + + let commandScript = lldbCommands.joined(separator: "\n") + try fileSystem.writeFileContents(lldbCommandFile, string: commandScript) + + // Return script file arguments without batch mode to allow interactive debugging + return ["-s", lldbCommandFile.pathString] + } + + /// Sets up multiple targets when both XCTest and Swift Testing are available + private func setupMultipleTargets(_ lldbCommands: inout [String]) throws { + var hasSwiftTesting = false + var hasXCTest = false + + for testingLibrary in target.targets { + let (executable, args) = try getExecutableAndArgs(for: testingLibrary) + lldbCommands.append("target create \(executable.pathString)") + lldbCommands.append("settings clear target.run-args") + + for arg in args { + lldbCommands.append("settings append target.run-args \"\(arg)\"") + } + + let modulePath = getModulePath(for: testingLibrary) + lldbCommands.append("target modules add \"\(modulePath.pathString)\"") + + if testingLibrary.library == .swiftTesting { + hasSwiftTesting = true + } else if testingLibrary.library == .xctest { + hasXCTest = true + } + } + + setupCommandAliases(&lldbCommands, hasSwiftTesting: hasSwiftTesting, hasXCTest: hasXCTest) + + // Create the target switching Python script + let scriptPath = try createTargetSwitchingScript() + lldbCommands.append("command script import \"\(scriptPath.pathString)\"") + + // Select the first target and launch with pause on main + lldbCommands.append("target select 0") + } + + /// Sets up a single target when only one testing library is available + private func setupSingleTarget(_ lldbCommands: inout [String], for target: DebuggableTestSession.Target) throws { + let (executable, args) = try getExecutableAndArgs(for: target) + // Create target + lldbCommands.append("target create \(executable.pathString)") + lldbCommands.append("settings clear target.run-args") + + // Add arguments + for arg in args { + lldbCommands.append("settings append target.run-args \"\(arg)\"") + } + + // Load symbols for the test bundle + let modulePath = getModulePath(for: target) + lldbCommands.append("target modules add \"\(modulePath.pathString)\"") + + setupCommandAliases(&lldbCommands, hasSwiftTesting: target.library == .swiftTesting, hasXCTest: target.library == .xctest) + } + + private func setupCommandAliases(_ lldbCommands: inout [String], hasSwiftTesting: Bool, hasXCTest: Bool) { + #if os(macOS) + let swiftTestingFailureBreakpoint = "-s Testing -n \"failureBreakpoint()\"" + let xctestFailureBreakpoint = "-n \"_XCTFailureBreakpoint\"" + #elseif os(Windows) + let swiftTestingFailureBreakpoint = "-s Testing.dll -n \"failureBreakpoint()\"" + let xctestFailureBreakpoint = "-s XCTest.dll -n \"XCTest.XCTestCase.recordFailure\"" + #else + let swiftTestingFailureBreakpoint = "-s libTesting.so -n \"Testing.failureBreakpoint\"" + let xctestFailureBreakpoint = "-s libXCTest.so -n \"XCTest.XCTestCase.recordFailure\"" + #endif + + // Add failure breakpoint commands based on available libraries + if hasSwiftTesting && hasXCTest { + lldbCommands.append("command alias failbreak script lldb.debugger.HandleCommand('breakpoint set \(swiftTestingFailureBreakpoint)'); lldb.debugger.HandleCommand('breakpoint set \(xctestFailureBreakpoint)')") + } else if hasSwiftTesting { + lldbCommands.append("command alias failbreak breakpoint set \(swiftTestingFailureBreakpoint)") + } else if hasXCTest { + lldbCommands.append("command alias failbreak breakpoint set \(xctestFailureBreakpoint)") + } + } + + /// Gets the executable path and arguments for a given testing library + private func getExecutableAndArgs(for target: DebuggableTestSession.Target) throws -> (AbsolutePath, [String]) { + switch target.library { + case .xctest: + #if os(macOS) + guard let xctestPath = toolchain.xctestPath else { + throw StringError("XCTest not found in toolchain") + } + return (xctestPath, [target.bundlePath.pathString] + target.additionalArgs) + #else + return (target.bundlePath, target.additionalArgs) + #endif + case .swiftTesting: + #if os(macOS) + let executable = try toolchain.getSwiftTestingHelper() + let args = ["--test-bundle-path", target.bundlePath.pathString] + target.additionalArgs + #else + let executable = target.bundlePath + let args = target.additionalArgs + #endif + return (executable, args) + } + } + + /// Gets the module path for symbol loading + private func getModulePath(for target: DebuggableTestSession.Target) -> AbsolutePath { + var modulePath = target.bundlePath + if target.library == .xctest && buildParameters.triple.isDarwin() { + if let name = target.bundlePath.components.last?.replacing(".xctest", with: "") { + if let relativePath = try? RelativePath(validating: "Contents/MacOS/\(name)") { + modulePath = target.bundlePath.appending(relativePath) + } + } + } + return modulePath + } + + /// Creates a Python script that handles automatic target switching + private func createTargetSwitchingScript() throws -> AbsolutePath { + let scriptPath = try pythonScriptFilePath() + + let pythonScript = """ +# target_switcher.py +import lldb +import threading +import time +import sys + +current_target_index = 0 +max_targets = 0 +debugger_ref = None +known_breakpoints = set() +sequence_active = True # Start active by default + +def sync_breakpoints_to_target(source_target, dest_target): + \"\"\"Synchronize breakpoints from source target to destination target.\"\"\" + if not source_target or not dest_target: + return + + def breakpoint_exists_in_target_by_spec(target, file_name, line_number, function_name): + \"\"\"Check if a breakpoint already exists in the target by specification.\"\"\" + for i in range(target.GetNumBreakpoints()): + existing_bp = target.GetBreakpointAtIndex(i) + if not existing_bp.IsValid(): + continue + + # Check function name breakpoints + if function_name: + # Get the breakpoint's function name specifications + names = lldb.SBStringList() + existing_bp.GetNames(names) + + # Check names from GetNames() + for j in range(names.GetSize()): + if names.GetStringAtIndex(j) == function_name: + return True + + # If no names found, check the description for pending breakpoints + if names.GetSize() == 0: + bp_desc = str(existing_bp).strip() + import re + match = re.search(r"name = '([^']+)'", bp_desc) + if match and match.group(1) == function_name: + return True + + # Check file/line breakpoints (only if resolved) + if file_name and line_number: + for j in range(existing_bp.GetNumLocations()): + location = existing_bp.GetLocationAtIndex(j) + if location.IsValid(): + addr = location.GetAddress() + line_entry = addr.GetLineEntry() + if line_entry.IsValid(): + existing_file_spec = line_entry.GetFileSpec() + existing_line_number = line_entry.GetLine() + if (existing_file_spec.GetFilename() == file_name and + existing_line_number == line_number): + return True + return False + + # Get all breakpoints from source target + for i in range(source_target.GetNumBreakpoints()): + bp = source_target.GetBreakpointAtIndex(i) + if not bp.IsValid(): + continue + + # Handle breakpoints by their specifications, not just resolved locations + # First check if this is a function name breakpoint + names = lldb.SBStringList() + bp.GetNames(names) + + # For pending breakpoints, GetNames() might be empty, so also check the description + bp_desc = str(bp).strip() + + # Extract function name from description if names is empty + function_names_to_sync = [] + if names.GetSize() > 0: + # Use the names from GetNames() + for j in range(names.GetSize()): + function_name = names.GetStringAtIndex(j) + if function_name: + function_names_to_sync.append(function_name) + else: + # Parse function name from description for pending breakpoints + # Description format: "1: name = 'failureBreakpoint()', module = Testing, locations = 0 (pending)" + import re + match = re.search(r"name = '([^']+)'", bp_desc) + if match: + function_name = match.group(1) + function_names_to_sync.append(function_name) + + # Sync the function name breakpoints + for function_name in function_names_to_sync: + if not breakpoint_exists_in_target_by_spec(dest_target, None, None, function_name): + new_bp = dest_target.BreakpointCreateByName(function_name) + if new_bp.IsValid(): + new_bp.SetEnabled(bp.IsEnabled()) + new_bp.SetCondition(bp.GetCondition()) + new_bp.SetIgnoreCount(bp.GetIgnoreCount()) + + # Handle resolved location-based breakpoints (file/line) + # Only process if the breakpoint has resolved locations + if bp.GetNumLocations() > 0: + for j in range(bp.GetNumLocations()): + location = bp.GetLocationAtIndex(j) + if not location.IsValid(): + continue + + addr = location.GetAddress() + line_entry = addr.GetLineEntry() + + if line_entry.IsValid(): + file_spec = line_entry.GetFileSpec() + line_number = line_entry.GetLine() + file_name = file_spec.GetFilename() + + # Check if this breakpoint already exists in destination target + if breakpoint_exists_in_target_by_spec(dest_target, file_name, line_number, None): + continue + + # Create the same breakpoint in the destination target + new_bp = dest_target.BreakpointCreateByLocation(file_spec, line_number) + if new_bp.IsValid(): + # Copy breakpoint properties + new_bp.SetEnabled(bp.IsEnabled()) + new_bp.SetCondition(bp.GetCondition()) + new_bp.SetIgnoreCount(bp.GetIgnoreCount()) + +def sync_breakpoints_to_all_targets(): + \"\"\"Synchronize breakpoints from current target to all other targets.\"\"\" + global debugger_ref, max_targets + + if not debugger_ref or max_targets <= 1: + return + + current_target = debugger_ref.GetSelectedTarget() + if not current_target: + return + + # Sync to all other targets + for i in range(max_targets): + target = debugger_ref.GetTargetAtIndex(i) + if target and target != current_target: + sync_breakpoints_to_target(current_target, target) + +def monitor_breakpoints(): + \"\"\"Monitor breakpoint changes and sync them across targets.\"\"\" + global debugger_ref, known_breakpoints, max_targets + + if max_targets <= 1: + return + + last_breakpoint_count = 0 + + while True: # Keep running forever, not just while current_target_index < max_targets + if debugger_ref: + current_target = debugger_ref.GetSelectedTarget() + if current_target: + current_bp_count = current_target.GetNumBreakpoints() + + # If breakpoint count changed, sync to all targets + if current_bp_count != last_breakpoint_count: + sync_breakpoints_to_all_targets() + last_breakpoint_count = current_bp_count + + time.sleep(0.5) # Check every 500ms + +def check_process_status(): + \"\"\"Periodically check if the current process has exited.\"\"\" + global current_target_index, max_targets, debugger_ref, sequence_active + + while True: # Keep running forever, don't exit + if debugger_ref: + target = debugger_ref.GetSelectedTarget() + if target: + process = target.GetProcess() + if process and process.GetState() == lldb.eStateExited: + # Process has exited + if sequence_active and current_target_index < max_targets: + # We're in an active sequence, trigger switch + current_target_index += 1 + + if current_target_index < max_targets: + # Switch to next target and launch immediately + print("\\n") + debugger_ref.HandleCommand(f'target select {current_target_index}') + print(" ") + + # Get target name for user feedback + new_target = debugger_ref.GetSelectedTarget() + target_name = new_target.GetExecutable().GetFilename() if new_target else "Unknown" + + # Launch the next target immediately with pause on main + debugger_ref.HandleCommand('process launch') # -m to pause on main + else: + # Reset to first target and deactivate sequence until user runs again + current_target_index = 0 + sequence_active = False # Pause automatic switching + + print("\\n") + debugger_ref.HandleCommand('target select 0') + print("\\nAll testing targets completed.") + print("Type 'run' to restart the entire test sequence from the beginning.\\n") + + # Clear the current line and move cursor to start + sys.stdout.write("\\033[2K\\r") + # Reprint a fake prompt + sys.stdout.write("(lldb) ") + sys.stdout.flush() + elif process and process.GetState() in [lldb.eStateRunning, lldb.eStateLaunching]: + # Process is running - if sequence was inactive, reactivate it + if not sequence_active: + sequence_active = True + # Find which target is currently selected to set the correct index + selected_target = debugger_ref.GetSelectedTarget() + if selected_target: + for i in range(max_targets): + if debugger_ref.GetTargetAtIndex(i) == selected_target: + current_target_index = i + break + + time.sleep(0.1) # Check every second + +def __lldb_init_module(debugger, internal_dict): + global max_targets, debugger_ref + + debugger_ref = debugger + + # Count the number of targets + max_targets = debugger.GetNumTargets() + + if max_targets > 1: + # Start the process status checker + status_thread = threading.Thread(target=check_process_status, daemon=True) + status_thread.start() + + # Start the breakpoint monitor + bp_thread = threading.Thread(target=monitor_breakpoints, daemon=True) + bp_thread.start() +""" + + try fileSystem.writeFileContents(scriptPath, string: pythonScript) + return scriptPath + } +} + extension SwiftCommandState { func buildParametersForTest( enableCodeCoverage: Bool, diff --git a/Sources/_InternalTestSupport/SwiftTesting+Helpers.swift b/Sources/_InternalTestSupport/SwiftTesting+Helpers.swift index 69e8b06fee0..cd3af6c3f2b 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Helpers.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Helpers.swift @@ -10,6 +10,8 @@ import Basics import Testing +import Foundation +import class TSCBasic.BufferedOutputByteStream public func expectFileExists( at path: AbsolutePath, @@ -116,3 +118,34 @@ public func expectAsyncThrowsError( errorHandler(error) } } + +/// Checks if an output stream contains a specific string, with retry logic for asynchronous writes. +/// - Parameters: +/// - outputStream: The output stream to check +/// - needle: The string to search for in the output stream +/// - timeout: Maximum time to wait for the string to appear (default: 3 seconds) +/// - retryInterval: Time to wait between checks (default: 50 milliseconds) +/// - Returns: True if the string was found within the timeout period +public func waitForOutputStreamToContain( + _ outputStream: BufferedOutputByteStream, + _ needle: String, + timeout: TimeInterval = 3.0, + retryInterval: TimeInterval = 0.05 +) async throws -> Bool { + let description = outputStream.bytes.description + if description.contains(needle) { + return true + } + + let startTime = Date() + while Date().timeIntervalSince(startTime) < timeout { + let description = outputStream.bytes.description + if description.contains(needle) { + return true + } + + try await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) + } + + return outputStream.bytes.description.contains(needle) +} \ No newline at end of file diff --git a/Sources/_InternalTestSupport/misc.swift b/Sources/_InternalTestSupport/misc.swift index e04298e5f47..2e6d7e7ac69 100644 --- a/Sources/_InternalTestSupport/misc.swift +++ b/Sources/_InternalTestSupport/misc.swift @@ -319,7 +319,7 @@ fileprivate func setup( #else try Process.checkNonZeroExit(args: "cp", "-R", "-H", srcDir.pathString, dstDir.pathString) #endif - + // Ensure we get a clean test fixture. try localFileSystem.removeFileTree(dstDir.appending(component: ".build")) try localFileSystem.removeFileTree(dstDir.appending(component: ".swiftpm")) @@ -522,14 +522,7 @@ private func swiftArgs( Xswiftc: [String], buildSystem: BuildSystemProvider.Kind? ) -> [String] { - var args = ["--configuration"] - switch configuration { - case .debug: - args.append("debug") - case .release: - args.append("release") - } - + var args = configuration.buildArgs args += Xcc.flatMap { ["-Xcc", $0] } args += Xld.flatMap { ["-Xlinker", $0] } args += Xswiftc.flatMap { ["-Xswiftc", $0] } @@ -538,7 +531,7 @@ private func swiftArgs( return args } -@available(*, +@available(*, deprecated, renamed: "loadModulesGraph", message: "Rename for consistency: the type of this functions return value is named `ModulesGraph`." @@ -571,6 +564,19 @@ public func loadPackageGraph( ) } +extension BuildConfiguration { + public var buildArgs: [String] { + var args = ["--configuration"] + switch self { + case .debug: + args.append("debug") + case .release: + args.append("release") + } + return args + } +} + public let emptyZipFile = ByteString([0x80, 0x75, 0x05, 0x06] + [UInt8](repeating: 0x00, count: 18)) extension FileSystem { @@ -682,7 +688,7 @@ public func getNumberOfMatches(of match: String, in value: String) -> Int { } public extension String { - var withSwiftLineEnding: String { + var withSwiftLineEnding: String { return replacingOccurrences(of: "\r\n", with: "\n") } } diff --git a/Tests/CommandsTests/TestCommandTests.swift b/Tests/CommandsTests/TestCommandTests.swift index 452526c9b12..0255f4e31a8 100644 --- a/Tests/CommandsTests/TestCommandTests.swift +++ b/Tests/CommandsTests/TestCommandTests.swift @@ -10,10 +10,11 @@ // //===----------------------------------------------------------------------===// -import Foundation +@testable import Commands +@testable import CoreCommands +import Foundation import Basics -import Commands import struct SPMBuildCore.BuildSystemProvider import enum PackageModel.BuildConfiguration import PackageModel @@ -21,6 +22,26 @@ import _InternalTestSupport import TSCTestSupport import Testing +import struct ArgumentParser.ExitCode +import protocol ArgumentParser.AsyncParsableCommand +import class TSCBasic.BufferedOutputByteStream + +fileprivate func execute( + _ args: [String], + packagePath: AbsolutePath? = nil, + configuration: BuildConfiguration = .debug, + buildSystem: BuildSystemProvider.Kind, + throwIfCommandFails: Bool = true +) async throws -> (stdout: String, stderr: String) { + try await executeSwiftTest( + packagePath, + configuration: configuration, + extraArgs: args, + throwIfCommandFails: throwIfCommandFails, + buildSystem: buildSystem, + ) +} + @Suite( .serialized, // to limit the number of swift executable running. .tags( @@ -30,22 +51,6 @@ import Testing ) struct TestCommandTests { - private func execute( - _ args: [String], - packagePath: AbsolutePath? = nil, - configuration: BuildConfiguration = .debug, - buildSystem: BuildSystemProvider.Kind, - throwIfCommandFails: Bool = true - ) async throws -> (stdout: String, stderr: String) { - try await executeSwiftTest( - packagePath, - configuration: configuration, - extraArgs: args, - throwIfCommandFails: throwIfCommandFails, - buildSystem: buildSystem, - ) - } - @Test( arguments: SupportedBuildSystemOnAllPlatforms, BuildConfiguration.allCases, ) @@ -1138,7 +1143,7 @@ struct TestCommandTests { try await fixture(name: "Miscellaneous/Errors/FatalErrorInSingleXCTest/TypeLibrary") { fixturePath in // WHEN swift-test is executed let error = await #expect(throws: SwiftPMError.self) { - try await self.execute( + try await execute( [], packagePath: fixturePath, configuration: configuration, @@ -1173,4 +1178,296 @@ struct TestCommandTests { } } + // MARK: - LLDB Flag Validation Tests + + @Suite + struct LLDBTests { + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithParallelThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger", "--parallel"], for: buildSystem) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + // The output stream is written to asynchronously on a DispatchQueue and can + // receive output after the command has thrown. + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with --parallel") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithNumWorkersThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger", "--parallel", "--num-workers", "2"], for: buildSystem) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + // Should hit the --parallel error first since validation is done in order + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with --parallel") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithNumWorkersOnlyThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger", "--num-workers", "2"], for: buildSystem) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with --num-workers") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithListTestsThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger", "--list-tests"], for: buildSystem) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with --list-tests") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithShowCodecovPathThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger", "--show-codecov-path"], for: buildSystem) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with --show-codecov-path") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func lldbWithReleaseConfigurationThrowsError(buildSystem: BuildSystemProvider.Kind) async throws { + let args = args(["--debugger"], for: buildSystem, buildConfiguration: .release) + let command = try #require(SwiftTestCommand.parseAsRoot(args) as? SwiftTestCommand) + let (state, outputStream) = try commandState() + + let error = await #expect(throws: ExitCode.self) { + try await command.run(state) + } + + #expect(error == ExitCode.failure, "Expected ExitCode.failure, got \(String(describing: error))") + + let found = try await waitForOutputStreamToContain(outputStream, "--debugger cannot be used with release configuration") + #expect( + found, + "Expected error about incompatible flags, got: \(outputStream.bytes.description)" + ) + } + + @Test( + .bug(id: 0, "SWBINTTODO: MacOS: Could not find or use auto-linked library 'Testing': library 'Testing' not found"), + arguments: SupportedBuildSystemOnAllPlatforms, + ) + func debuggerFlagWithXCTestSuite(buildSystem: BuildSystemProvider.Kind) async throws { + try await withKnownIssue( + """ + MacOS, .swiftbuild: Could not find or use auto-linked library 'Testing': library 'Testing' not found + Windows: Missing LLDB DLLs w/ ARM64 toolchain + """ + ) { + let configuration = BuildConfiguration.debug + try await fixture(name: "Miscellaneous/TestDebugging") { fixturePath in + let (stdout, stderr) = try await execute( + ["--debugger", "--disable-swift-testing", "--verbose"], + packagePath: fixturePath, + configuration: configuration, + buildSystem: buildSystem, + ) + + #expect( + !stderr.contains("error: --debugger cannot be used with"), + "got stdout: \(stdout), stderr: \(stderr)", + ) + + #if os(macOS) + let targetName = "xctest" + #else + let targetName = buildSystem == .swiftbuild ? "test-runner" : "xctest" + #endif + + #expect( + stdout.contains("target create") && stdout.contains(targetName), + "Expected LLDB to target xctest binary, got stdout: \(stdout), stderr: \(stderr)", + ) + + #expect( + stdout.contains("failbreak breakpoint set"), + "Expected a failure breakpoint to be setup, got stdout: \(stdout), stderr: \(stderr)", + ) + } + } when: { + (buildSystem == .swiftbuild && ProcessInfo.hostOperatingSystem == .macOS && CiEnvironment.runningInSelfHostedPipeline) + || (ProcessInfo.hostOperatingSystem == .windows && CiEnvironment.runningInSelfHostedPipeline) + } + } + + @Test( + .bug(id: 0, "SWBINTTODO: MacOS: Could not find or use auto-linked library 'Testing': library 'Testing' not found"), + arguments: SupportedBuildSystemOnAllPlatforms + ) + func debuggerFlagWithSwiftTestingSuite(buildSystem: BuildSystemProvider.Kind) async throws { + try await withKnownIssue( + """ + MacOS, .swiftbuild: Could not find or use auto-linked library 'Testing': library 'Testing' not found + Windows: Missing LLDB DLLs w/ ARM64 toolchain + """ + ) { + let configuration = BuildConfiguration.debug + try await fixture(name: "Miscellaneous/TestDebugging") { fixturePath in + let (stdout, stderr) = try await execute( + ["--debugger", "--disable-xctest", "--verbose"], + packagePath: fixturePath, + configuration: configuration, + buildSystem: buildSystem, + ) + + #expect( + !stderr.contains("error: --debugger cannot be used with"), + "got stdout: \(stdout), stderr: \(stderr)", + ) + + #if os(macOS) + let targetName = "swiftpm-testing-helper" + #else + let targetName = buildSystem == .native ? "TestDebuggingPackageTests.xctest" : "TestDebuggingTests-test-runner" + #endif + + #expect( + stdout.contains("target create") && stdout.contains(targetName), + "Expected LLDB to target swiftpm-testing-helper binary, got stdout: \(stdout), stderr: \(stderr)", + ) + + #expect( + stdout.contains("failbreak breakpoint set"), + "Expected Swift Testing failure breakpoint setup, got stdout: \(stdout), stderr: \(stderr)", + ) + } + } when: { + (buildSystem == .swiftbuild && ProcessInfo.hostOperatingSystem == .macOS && CiEnvironment.runningInSelfHostedPipeline) + || (ProcessInfo.hostOperatingSystem == .windows && CiEnvironment.runningInSelfHostedPipeline) + } + } + + @Test( + .bug(id: 0, "SWBINTTODO: MacOS: Could not find or use auto-linked library 'Testing': library 'Testing' not found"), + arguments: SupportedBuildSystemOnAllPlatforms + ) + func debuggerFlagWithBothTestingSuites(buildSystem: BuildSystemProvider.Kind) async throws { + try await withKnownIssue( + """ + MacOS, .swiftbuild: Could not find or use auto-linked library 'Testing': library 'Testing' not found + Windows: Missing LLDB DLLs w/ ARM64 toolchain + """ + ) { + let configuration = BuildConfiguration.debug + try await fixture(name: "Miscellaneous/TestDebugging") { fixturePath in + let (stdout, stderr) = try await execute( + ["--debugger", "--verbose"], + packagePath: fixturePath, + configuration: configuration, + buildSystem: buildSystem, + ) + + #expect( + !stderr.contains("error: --debugger cannot be used with"), + "got stdout: \(stdout), stderr: \(stderr)", + ) + + #expect( + stdout.contains("target create"), + "Expected LLDB to create targets, got stdout: \(stdout), stderr: \(stderr)", + ) + + #expect( + getNumberOfMatches(of: "breakpoint set", in: stdout) == 2, + "Expected combined failure breakpoint setup, got stdout: \(stdout), stderr: \(stderr)", + ) + + #expect( + stdout.contains("command script import"), + "Expected Python script import for multi-target switching, got stdout: \(stdout), stderr: \(stderr)", + ) + } + } when: { + (buildSystem == .swiftbuild && ProcessInfo.hostOperatingSystem == .macOS && CiEnvironment.runningInSelfHostedPipeline) + || (ProcessInfo.hostOperatingSystem == .windows && CiEnvironment.runningInSelfHostedPipeline) + } + } + + func args(_ args: [String], for buildSystem: BuildSystemProvider.Kind, buildConfiguration: BuildConfiguration = .debug) -> [String] { + return args + buildConfiguration.buildArgs + getBuildSystemArgs(for: buildSystem) + } + + func commandState() throws -> (SwiftCommandState, BufferedOutputByteStream) { + let outputStream = BufferedOutputByteStream() + + let state = try SwiftCommandState( + outputStream: outputStream, + options: try GlobalOptions.parse([]), + toolWorkspaceConfiguration: .init(shouldInstallSignalHandlers: false), + workspaceDelegateProvider: { + CommandWorkspaceDelegate( + observabilityScope: $0, + outputHandler: $1, + progressHandler: $2, + inputHandler: $3 + ) + }, + workspaceLoaderProvider: { + XcodeWorkspaceLoader( + fileSystem: $0, + observabilityScope: $1 + ) + }, + createPackagePath: false + ) + return (state, outputStream) + } + } } +