From 92075dbe8601bf9132999c97dafb0a6843eb3843 Mon Sep 17 00:00:00 2001 From: Bassam Khouri Date: Fri, 21 Aug 2026 22:55:31 -0400 Subject: [PATCH] Deprecate XCTest test helpers in favor of Swift Testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that all test suites in the repository have been migrated from XCTest to Swift Testing, mark every XCTest-flavored public helper in `ArgumentParserTestHelpers` as `@available(*, deprecated, message: ...)` so downstream users see a compile-time nudge toward the Swift-Testing-native replacement. Deprecated helpers and their replacements: - `TestableParsableArguments` → `TestableSwiftTestingParsableArguments` - `TestableParsableCommand` → `TestableSwiftTestingParsableCommand` - `XCTestExpectation.init(singleExpectation:)` → `TestExpectation` - `AssertResultFailure` → `expectResultFailure` - `AssertErrorMessage` → `expectErrorMessage` - `AssertFullErrorMessage` → `expectFullErrorMessage` - `AssertParse` → `expectParse` - `AssertParseCommand` → `expectParseCommand` - `AssertParseCommandErrorMessage` → (no direct replacement; inline with `#expect` / `Issue.record`) - `AssertEqualStrings` → `expectEqualStrings` - `AssertExecuteCommand` → `requireExecuteCommand` - `AssertJSONEqualFromString` → `expectJSONEqualFromString` - `assertSnapshot` → `expectSnapshot` - `assertGenerateManual` → `expectGenerateManual` - `assertGeneratedReference` → `expectGeneratedReference` - `assertDumpHelp(type:)` / `assertDumpHelp(command:)` → `expectDumpHelp(type:)` / `expectDumpHelp(command:)` - `XCTest.debugURL` — no public replacement; the Swift Testing helpers derive it internally via a `Bundle`-marker class. To keep the deprecated helpers callable in-file without emitting cascading deprecation warnings, extract their shared implementation into non-deprecated `fileprivate` helpers (`_AssertExecuteCommand`, `_assertEqualStrings`, `_assertSnapshot`, `_debugBundleURL`). The public deprecated overloads forward to these. The six `expect*` helpers in `TestHelpers+SwiftTesting.swift` no longer delegate to their `Assert*` counterparts — inlining their Swift-Testing branches lets us deprecate the `Assert*` variants without warning at those call sites. `requireHelp` moves from `TestHelpers.swift` to `TestHelpers+SwiftTesting.swift` (it was already Swift-Testing-native) and now calls `expectEqualStrings` instead of `AssertEqualStrings`. Relates to #710 --- .../TestHelpers+SwiftTesting.swift | 216 ++++++++- .../TestHelpers.swift | 447 ++++++++++-------- .../CountLinesExampleTests.swift | 4 +- .../RepeatExampleTests.swift | 4 +- .../RollDiceExampleTests.swift | 4 +- Tests/ArgumentParserUnitTests/CMakeLists.txt | 1 + .../SerializedCompletionSuites.swift | 26 + 7 files changed, 496 insertions(+), 206 deletions(-) create mode 100644 Tests/ArgumentParserUnitTests/SerializedCompletionSuites.swift diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers+SwiftTesting.swift b/Sources/ArgumentParserTestHelpers/TestHelpers+SwiftTesting.swift index 2678d0a3d..7506740cb 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers+SwiftTesting.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers+SwiftTesting.swift @@ -28,23 +28,45 @@ public func expectResultFailure( _ message: @autoclosure () -> String = "", sourceLocation: SourceLocation = #_sourceLocation ) { - AssertResultFailure(expression(), message(), sourceLocation: sourceLocation) + switch expression() { + case .success: + let msg = message() + Issue.record( + msg.isEmpty ? "Incorrectly succeeded" : "\(msg)", + sourceLocation: sourceLocation) + case .failure: + break + } } public func expectErrorMessage( _ type: A.Type, _ arguments: [String], _ errorMessage: String, sourceLocation: SourceLocation = #_sourceLocation ) { - AssertErrorMessage( - type, arguments, errorMessage, sourceLocation: sourceLocation) + do { + _ = try A.parse(arguments) + Issue.record( + "Parsing should have failed.", sourceLocation: sourceLocation) + } catch { + #expect( + A.message(for: error) == errorMessage, + sourceLocation: sourceLocation) + } } public func expectFullErrorMessage( _ type: A.Type, _ arguments: [String], _ errorMessage: String, sourceLocation: SourceLocation = #_sourceLocation ) { - AssertFullErrorMessage( - type, arguments, errorMessage, sourceLocation: sourceLocation) + do { + _ = try A.parse(arguments) + Issue.record( + "Parsing should have failed.", sourceLocation: sourceLocation) + } catch { + #expect( + A.fullMessage(for: error) == errorMessage, + sourceLocation: sourceLocation) + } } public func expectParse( @@ -52,8 +74,14 @@ public func expectParse( sourceLocation: SourceLocation = #_sourceLocation, closure: (A) throws -> Void ) { - AssertParse(type, arguments, sourceLocation: sourceLocation) { - try closure($0) + do { + let parsed = try type.parse(arguments) + try closure(parsed) + } catch { + let message = type.message(for: error) + Issue.record( + "\"\(message)\" — \(error)", + sourceLocation: sourceLocation) } } @@ -62,10 +90,20 @@ public func expectParseCommand( sourceLocation: SourceLocation = #_sourceLocation, closure: (A) throws -> Void ) { - AssertParseCommand( - rootCommand, type, arguments, sourceLocation: sourceLocation - ) { - try closure($0) + do { + let command = try rootCommand.parseAsRoot(arguments) + guard let aCommand = command as? A else { + Issue.record( + "Command is of unexpected type: \(command)", + sourceLocation: sourceLocation) + return + } + try closure(aCommand) + } catch { + let message = rootCommand.message(for: error) + Issue.record( + "\"\(message)\" — \(error)", + sourceLocation: sourceLocation) } } @@ -74,8 +112,73 @@ public func expectEqualStrings( expected: String, sourceLocation: SourceLocation = #_sourceLocation ) { - AssertEqualStrings( - actual: actual, expected: expected, sourceLocation: sourceLocation) + // Normalize line endings to '\n'. + let actual = + actual + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let expected = + expected + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + + // If the input strings are equal, early exit. + guard actual != expected else { return } + + let stringComparison: String + + // If collectionDifference is available, use it to make a nicer error message. + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + let actualLines = actual.components(separatedBy: .newlines) + let expectedLines = expected.components(separatedBy: .newlines) + + let difference = actualLines.difference(from: expectedLines) + + var result = "" + + var insertions: [Int: String] = [:] + var removals: [Int: String] = [:] + + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var expectedLine = 0 + var actualLine = 0 + + while expectedLine < expectedLines.count || actualLine < actualLines.count { + if let removal = removals[expectedLine] { + result += "–\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + + stringComparison = result + } else { + stringComparison = """ + Expected: + \(expected) + + Actual: + \(actual) + """ + } + + Issue.record( + "Actual output does not match the expected output:\n\(stringComparison)", + sourceLocation: sourceLocation) } @discardableResult @@ -380,3 +483,90 @@ public func expectGeneratedReference( sourceLocation: sourceLocation) #endif } + +// swift-format-ignore: AlwaysUseLowerCamelCase +public func requireHelp( + _ visibility: ArgumentVisibility, + for _: T.Type, + columns: Int? = 80, + equals expected: String, + sourceLocation: SourceLocation = #_sourceLocation +) throws { + let flag: String + let includeHidden: Bool + + switch visibility { + case .default: + flag = "--help" + includeHidden = false + case .hidden: + flag = "--help-hidden" + includeHidden = true + case .private: + Issue.record("Should not be called.", sourceLocation: sourceLocation) + return + default: + Issue.record("Uxnrecognized visibility.", sourceLocation: sourceLocation) + return + } + + #if compiler(>=6.1) + let error = try #require(throws: (any Error).self) { + _ = try T.parse([flag]) + } + #else + let error: any Error + do { + _ = try T.parse([flag]) + Issue.record( + "Expected T.parse to throw an error.", + sourceLocation: sourceLocation) + return + } catch let caught { + error = caught + } + #endif + let errorFullMessage = T.fullMessage(for: error, columns: columns) + expectEqualStrings( + actual: errorFullMessage, + expected: expected, + sourceLocation: sourceLocation + ) + + let helpString = T.helpMessage(includeHidden: includeHidden, columns: columns) + expectEqualStrings( + actual: helpString, + expected: expected, + sourceLocation: sourceLocation + ) +} + +// swift-format-ignore: AlwaysUseLowerCamelCase +public func requireHelp( + _ visibility: ArgumentVisibility, + for _: T.Type, + root _: U.Type, + columns: Int? = 80, + equals expected: String, + sourceLocation: SourceLocation = #_sourceLocation +) throws { + let includeHidden: Bool + + switch visibility { + case .default: + includeHidden = false + case .hidden: + includeHidden = true + case .private: + Issue.record("Should not be called.", sourceLocation: sourceLocation) + return + default: + Issue.record("Uxnrecognized visibility.", sourceLocation: sourceLocation) + return + } + + let helpString = U.helpMessage( + for: T.self, includeHidden: includeHidden, columns: columns) + expectEqualStrings( + actual: helpString, expected: expected, sourceLocation: sourceLocation) +} diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers.swift b/Sources/ArgumentParserTestHelpers/TestHelpers.swift index 5d5cf4f39..7aeed9902 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift Argument Parser open source project // -// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Copyright (c) 2020-2026 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -11,7 +11,6 @@ import ArgumentParser import ArgumentParserToolInfo -import Testing import XCTest @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @@ -45,17 +44,28 @@ where ChangeElement: Equatable { } // extensions to the ParsableArguments protocol to facilitate XCTestExpectation support +@available( + *, deprecated, + message: + "Migrate to Swift Testing and use `TestableSwiftTestingParsableArguments` with `TestExpectation` instead." +) public protocol TestableParsableArguments: ParsableArguments { var didValidateExpectation: XCTestExpectation { get } } extension TestableParsableArguments { + @available(*, deprecated) public mutating func validate() throws { didValidateExpectation.fulfill() } } // extensions to the ParsableCommand protocol to facilitate XCTestExpectation support +@available( + *, deprecated, + message: + "Migrate to Swift Testing and use `TestableSwiftTestingParsableCommand` with `TestExpectation` instead." +) public protocol TestableParsableCommand: ParsableCommand, TestableParsableArguments { @@ -63,12 +73,18 @@ public protocol TestableParsableCommand: ParsableCommand, } extension TestableParsableCommand { + @available(*, deprecated) public mutating func run() throws { didRunExpectation.fulfill() } } extension XCTestExpectation { + @available( + *, deprecated, + message: + "Migrate to Swift Testing and use `TestExpectation` from `ArgumentParserTestHelpers` instead." + ) public convenience init(singleExpectation description: String) { self.init(description: description) expectedFulfillmentCount = 1 @@ -77,93 +93,75 @@ extension XCTestExpectation { } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectResultFailure` instead." +) public func AssertResultFailure( _ expression: @autoclosure () -> Result, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, - line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation + line: UInt = #line ) { switch expression() { case .success: let msg = message() - if Test.current != nil { - Issue.record( - msg.isEmpty ? "Incorrectly succeeded" : "\(msg)", - sourceLocation: sourceLocation) - } else { - XCTFail( - msg.isEmpty ? "Incorrectly succeeded" : msg, file: file, line: line) - } + XCTFail( + msg.isEmpty ? "Incorrectly succeeded" : msg, file: file, line: line) case .failure: break } } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectErrorMessage` instead." +) public func AssertErrorMessage( _ type: A.Type, _ arguments: [String], _ errorMessage: String, file: StaticString = #filePath, - line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation + line: UInt = #line ) where A: ParsableArguments { do { _ = try A.parse(arguments) - if Test.current != nil { - Issue.record( - "Parsing should have failed.", sourceLocation: sourceLocation) - } else { - XCTFail("Parsing should have failed.", file: file, line: line) - } + XCTFail("Parsing should have failed.", file: file, line: line) } catch { // We expect to hit this path, i.e. getting an error: - if Test.current != nil { - #expect( - A.message(for: error) == errorMessage, - sourceLocation: sourceLocation - ) - } else { - XCTAssertEqual( - A.message(for: error), errorMessage, file: file, line: line) - } + XCTAssertEqual( + A.message(for: error), errorMessage, file: file, line: line) } } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectFullErrorMessage` instead." +) public func AssertFullErrorMessage( _ type: A.Type, _ arguments: [String], _ errorMessage: String, file: StaticString = #filePath, - line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation + line: UInt = #line ) where A: ParsableArguments { do { _ = try A.parse(arguments) - if Test.current != nil { - Issue.record( - "Parsing should have failed.", sourceLocation: sourceLocation) - } else { - XCTFail("Parsing should have failed.", file: (file), line: line) - } + XCTFail("Parsing should have failed.", file: (file), line: line) } catch { // We expect to hit this path, i.e. getting an error: - if Test.current != nil { - #expect( - A.fullMessage(for: error) == errorMessage, - sourceLocation: sourceLocation - ) - } else { - XCTAssertEqual( - A.fullMessage(for: error), errorMessage, file: (file), line: line) - } + XCTAssertEqual( + A.fullMessage(for: error), errorMessage, file: (file), line: line) } } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectParse` instead." +) public func AssertParse( _ type: A.Type, _ arguments: [String], file: StaticString = #filePath, line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation, closure: (A) throws -> Void ) where A: ParsableArguments { do { @@ -171,54 +169,41 @@ public func AssertParse( try closure(parsed) } catch { let message = type.message(for: error) - if Test.current != nil { - Issue.record( - "\"\(message)\" — \(error)", - sourceLocation: sourceLocation - ) - } else { - XCTFail("\"\(message)\" — \(error)", file: (file), line: line) - } + XCTFail("\"\(message)\" — \(error)", file: (file), line: line) } } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectParseCommand` instead." +) public func AssertParseCommand( _ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String], file: StaticString = #filePath, line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation, closure: (A) throws -> Void ) { do { let command = try rootCommand.parseAsRoot(arguments) guard let aCommand = command as? A else { - if Test.current != nil { - Issue.record( - "Command is of unexpected type: \(command)", - sourceLocation: sourceLocation - ) - } else { - XCTFail( - "Command is of unexpected type: \(command)", file: (file), line: line) - } + XCTFail( + "Command is of unexpected type: \(command)", file: (file), line: line) return } try closure(aCommand) } catch { let message = rootCommand.message(for: error) - if Test.current != nil { - Issue.record( - "\"\(message)\" — \(error)", - sourceLocation: sourceLocation - ) - } else { - XCTFail("\"\(message)\" — \(error)", file: file, line: line) - } + XCTFail("\"\(message)\" — \(error)", file: file, line: line) } } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: + "Migrate to Swift Testing. There is no direct replacement; inline the parse-and-check-message logic using `#expect` and `Issue.record`." +) public func AssertParseCommandErrorMessage( _ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String], _ errorMessage: String, @@ -240,12 +225,15 @@ public func AssertParseCommandErrorMessage( } // swift-format-ignore: AlwaysUseLowerCamelCase +@available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectEqualStrings` instead." +) public func AssertEqualStrings( actual: String, expected: String, file: StaticString = #filePath, - line: UInt = #line, - sourceLocation: SourceLocation = #_sourceLocation + line: UInt = #line ) { // Normalize line endings to '\n'. let actual = @@ -314,107 +302,19 @@ public func AssertEqualStrings( """ } - if Test.current != nil { - Issue.record( - "Actual output does not match the expected output:\n\(stringComparison)", - sourceLocation: sourceLocation - ) - } else { - XCTFail( - "Actual output does not match the expected output:\n\(stringComparison)", - file: file, - line: line) - } -} - -// swift-format-ignore: AlwaysUseLowerCamelCase -public func requireHelp( - _ visibility: ArgumentVisibility, - for _: T.Type, - columns: Int? = 80, - equals expected: String, - sourceLocation: SourceLocation = #_sourceLocation -) throws { - let flag: String - let includeHidden: Bool - - switch visibility { - case .default: - flag = "--help" - includeHidden = false - case .hidden: - flag = "--help-hidden" - includeHidden = true - case .private: - Issue.record("Should not be called.", sourceLocation: sourceLocation) - return - default: - Issue.record("Uxnrecognized visibility.", sourceLocation: sourceLocation) - return - } - - #if compiler(>=6.1) - let error = try #require(throws: (any Error).self) { - _ = try T.parse([flag]) - } - #else - let error: any Error - do { - _ = try T.parse([flag]) - Issue.record( - "Expected T.parse to throw an error.", - sourceLocation: sourceLocation) - return - } catch let caught { - error = caught - } - #endif - let errorFullMessage = T.fullMessage(for: error, columns: columns) - AssertEqualStrings( - actual: errorFullMessage, - expected: expected, - sourceLocation: sourceLocation + XCTFail( + "Actual output does not match the expected output:\n\(stringComparison)", + file: file, + line: line ) - - let helpString = T.helpMessage(includeHidden: includeHidden, columns: columns) - AssertEqualStrings( - actual: helpString, - expected: expected, - sourceLocation: sourceLocation - ) -} - -// swift-format-ignore: AlwaysUseLowerCamelCase -public func requireHelp( - _ visibility: ArgumentVisibility, - for _: T.Type, - root _: U.Type, - columns: Int? = 80, - equals expected: String, - sourceLocation: SourceLocation = #_sourceLocation -) throws { - let includeHidden: Bool - - switch visibility { - case .default: - includeHidden = false - case .hidden: - includeHidden = true - case .private: - Issue.record("Should not be called.", sourceLocation: sourceLocation) - return - default: - Issue.record("Uxnrecognized visibility.", sourceLocation: sourceLocation) - return - } - - let helpString = U.helpMessage( - for: T.self, includeHidden: includeHidden, columns: columns) - AssertEqualStrings( - actual: helpString, expected: expected, sourceLocation: sourceLocation) } extension XCTest { + @available( + *, deprecated, + message: + "Migrate to Swift Testing. `debugURL` has no direct public replacement; the Swift Testing helpers derive it internally." + ) public var debugURL: URL { let bundleURL = Bundle(for: type(of: self)).bundleURL return bundleURL.lastPathComponent.hasSuffix("xctest") @@ -423,6 +323,10 @@ extension XCTest { } // swift-format-ignore: AlwaysUseLowerCamelCase + @available( + *, deprecated, + message: "Migrate to Swift Testing and use `requireExecuteCommand` instead." + ) @discardableResult public func AssertExecuteCommand( command: String, @@ -432,7 +336,7 @@ extension XCTest { line: UInt = #line, environment: [String: String] = [:] ) throws -> String { - try AssertExecuteCommand( + try _AssertExecuteCommand( command: command.split(separator: " ").map(String.init), expected: expected, exitCode: exitCode, @@ -443,6 +347,10 @@ extension XCTest { } // swift-format-ignore: AlwaysUseLowerCamelCase + @available( + *, deprecated, + message: "Migrate to Swift Testing and use `requireExecuteCommand` instead." + ) @discardableResult public func AssertExecuteCommand( command: [String], @@ -451,6 +359,29 @@ extension XCTest { file: StaticString = #filePath, line: UInt = #line, environment: [String: String] = [:] + ) throws -> String { + try _AssertExecuteCommand( + command: command, + expected: expected, + exitCode: exitCode, + file: file, + line: line, + environment: environment + ) + } + + // Internal non-deprecated helper so the two `AssertExecuteCommand` + // overloads (and other deprecated helpers below) can share the + // implementation without themselves emitting deprecation warnings on + // internal calls. + @discardableResult + fileprivate func _AssertExecuteCommand( + command: [String], + expected: String? = nil, + exitCode: ExitCode = .success, + file: StaticString = #filePath, + line: UInt = #line, + environment: [String: String] = [:] ) throws -> String { #if os(Windows) throw XCTSkip("Unsupported on this platform") @@ -458,7 +389,7 @@ extension XCTest { let arguments = Array(command.dropFirst()) let commandName = String(command.first!) - let commandURL = debugURL.appendingPathComponent(commandName) + let commandURL = _debugBundleURL.appendingPathComponent(commandName) guard (try? commandURL.checkResourceIsReachable()) ?? false else { XCTFail( "No executable at '\(commandURL.standardizedFileURL.path)'.", @@ -498,7 +429,7 @@ extension XCTest { let errorActual = String(data: errorData, encoding: .utf8)! if let expected = expected { - AssertEqualStrings( + _assertEqualStrings( actual: errorActual + outputActual, expected: expected, file: file, @@ -513,12 +444,24 @@ extension XCTest { return outputActual } + fileprivate var _debugBundleURL: URL { + let bundleURL = Bundle(for: type(of: self)).bundleURL + return bundleURL.lastPathComponent.hasSuffix("xctest") + ? bundleURL.deletingLastPathComponent() + : bundleURL + } + // swift-format-ignore: AlwaysUseLowerCamelCase + @available( + *, deprecated, + message: + "Migrate to Swift Testing and use `expectJSONEqualFromString` instead." + ) public func AssertJSONEqualFromString( actual: String, expected: String, for type: T.Type, file: StaticString = #filePath, line: UInt = #line ) throws { - AssertEqualStrings( + _assertEqualStrings( actual: actual, expected: expected, file: file, @@ -536,10 +479,85 @@ extension XCTest { ) XCTAssertEqual(actualDumpJSON, expectedDumpJSON) } + + // Non-deprecated implementation of the string-equality assertion so the + // other helper methods in this file can call it without emitting + // deprecation warnings on their internal call sites. + fileprivate func _assertEqualStrings( + actual: String, + expected: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let actual = + actual + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let expected = + expected + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + + guard actual != expected else { return } + + let stringComparison: String + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + let actualLines = actual.components(separatedBy: .newlines) + let expectedLines = expected.components(separatedBy: .newlines) + let difference = actualLines.difference(from: expectedLines) + + var insertions: [Int: String] = [:] + var removals: [Int: String] = [:] + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var result = "" + var expectedLine = 0 + var actualLine = 0 + while expectedLine < expectedLines.count || actualLine < actualLines.count + { + if let removal = removals[expectedLine] { + result += "–\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + stringComparison = result + } else { + stringComparison = """ + Expected: + \(expected) + + Actual: + \(actual) + """ + } + + XCTFail( + "Actual output does not match the expected output:\n\(stringComparison)", + file: file, + line: line) + } } // MARK: - Snapshot testing extension XCTest { + @available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectSnapshot` instead." + ) @discardableResult public func assertSnapshot( actual: String, @@ -548,6 +566,24 @@ extension XCTest { test: StaticString = #function, file: StaticString = #filePath, line: UInt = #line + ) throws -> String? { + try _assertSnapshot( + actual: actual, + extension: `extension`, + record: record, + test: test, + file: file, + line: line) + } + + @discardableResult + fileprivate func _assertSnapshot( + actual: String, + extension: String, + record: Bool = false, + test: StaticString = #function, + file: StaticString = #filePath, + line: UInt = #line ) throws -> String? { let snapshotDirectoryURL = URL(fileURLWithPath: "\(file)") .deletingLastPathComponent() @@ -573,7 +609,7 @@ extension XCTest { return nil } else { let expected = try String(contentsOf: snapshotFileURL, encoding: .utf8) - AssertEqualStrings( + _assertEqualStrings( actual: actual, expected: expected, file: file, @@ -582,6 +618,10 @@ extension XCTest { } } + @available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectGenerateManual` instead." + ) public func assertGenerateManual( multiPage: Bool, command: String, @@ -594,7 +634,7 @@ extension XCTest { throw XCTSkip("Unsupported on this platform") #endif - let commandURL = debugURL.appendingPathComponent(command) + let commandURL = _debugBundleURL.appendingPathComponent(command) var command = [ "generate-manual", commandURL.path, "--date", "1996-05-12", @@ -607,12 +647,12 @@ extension XCTest { if multiPage { command.append("--multi-page") } - let actual = try AssertExecuteCommand( + let actual = try _AssertExecuteCommand( command: command, file: file, line: line) - try self.assertSnapshot( + try _assertSnapshot( actual: actual, extension: "mdoc", record: record, @@ -621,6 +661,11 @@ extension XCTest { line: line) } + @available( + *, deprecated, + message: + "Migrate to Swift Testing and use `expectGeneratedReference` instead." + ) public func assertGeneratedReference( command: String, doccFlavored: Bool, @@ -633,7 +678,7 @@ extension XCTest { throw XCTSkip("Unsupported on this platform") #endif - let commandURL = debugURL.appendingPathComponent(command) + let commandURL = _debugBundleURL.appendingPathComponent(command) let command: [String] if doccFlavored { command = [ @@ -647,12 +692,12 @@ extension XCTest { "--output-directory", "-", ] } - let actual = try AssertExecuteCommand( + let actual = try _AssertExecuteCommand( command: command, file: file, line: line) - try self.assertSnapshot( + try _assertSnapshot( actual: actual, extension: "md", record: record, @@ -661,6 +706,10 @@ extension XCTest { line: line) } + @available( + *, deprecated, + message: "Migrate to Swift Testing and use `expectDumpHelp(type:)` instead." + ) public func assertDumpHelp( type: T.Type, record: Bool = false, @@ -678,9 +727,9 @@ extension XCTest { } let apiOutput = T._dumpHelp() - AssertEqualStrings(actual: actual, expected: apiOutput) + _assertEqualStrings(actual: actual, expected: apiOutput) - let expected = try self.assertSnapshot( + let expected = try _assertSnapshot( actual: actual, extension: "json", record: record, @@ -690,14 +739,31 @@ extension XCTest { guard let expected else { return } - try AssertJSONEqualFromString( + _assertEqualStrings( actual: actual, expected: expected, - for: ToolInfoV0.self, file: file, line: line) + + let actualJSONData = try XCTUnwrap( + actual.data(using: .utf8), file: file, line: line) + let actualDumpJSON = try XCTUnwrap( + JSONDecoder().decode(ToolInfoV0.self, from: actualJSONData), + file: file, line: line) + + let expectedJSONData = try XCTUnwrap( + expected.data(using: .utf8), file: file, line: line) + let expectedDumpJSON = try XCTUnwrap( + JSONDecoder().decode(ToolInfoV0.self, from: expectedJSONData), + file: file, line: line) + XCTAssertEqual(actualDumpJSON, expectedDumpJSON) } + @available( + *, deprecated, + message: + "Migrate to Swift Testing and use `expectDumpHelp(command:)` instead." + ) public func assertDumpHelp( command: String, record: Bool = false, @@ -705,12 +771,13 @@ extension XCTest { file: StaticString = #filePath, line: UInt = #line ) throws { - let actual = try AssertExecuteCommand( - command: command + " --experimental-dump-help", + let actual = try _AssertExecuteCommand( + command: (command + " --experimental-dump-help").split(separator: " ") + .map(String.init), expected: nil, file: file, line: line) - try self.assertSnapshot( + try _assertSnapshot( actual: actual, extension: "json", record: record, diff --git a/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift b/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift index b4bc41450..0090aa4fa 100644 --- a/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift @@ -17,7 +17,9 @@ import Testing @testable import ArgumentParser -@Suite(.serialized) struct CountLinesExampleTests { +@Suite( + .serialized +) struct CountLinesExampleTests { init() { Platform.Environment[.columns] = nil } diff --git a/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift b/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift index 0c7c2c35c..692fc0914 100644 --- a/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift @@ -14,7 +14,9 @@ import Testing @testable import ArgumentParser -@Suite(.serialized) struct RepeatExampleTests { +@Suite( + .serialized +) struct RepeatExampleTests { init() { Platform.Environment[.columns] = nil } diff --git a/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift b/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift index 2460c7828..20001a0f6 100644 --- a/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift @@ -14,7 +14,9 @@ import Testing @testable import ArgumentParser -@Suite(.serialized) struct RollDiceExampleTests { +@Suite( + .serialized +) struct RollDiceExampleTests { init() { Platform.Environment[.columns] = nil } diff --git a/Tests/ArgumentParserUnitTests/CMakeLists.txt b/Tests/ArgumentParserUnitTests/CMakeLists.txt index 019179208..6e2bfaf03 100644 --- a/Tests/ArgumentParserUnitTests/CMakeLists.txt +++ b/Tests/ArgumentParserUnitTests/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(UnitTests HelpGenerationTests+GroupName.swift HelpGenerationTests+HelpBanner.swift NameSpecificationTests.swift + SerializedCompletionSuites.swift SplitArgumentTests.swift StringSnakeCaseTests.swift StringWrappingTests.swift diff --git a/Tests/ArgumentParserUnitTests/SerializedCompletionSuites.swift b/Tests/ArgumentParserUnitTests/SerializedCompletionSuites.swift new file mode 100644 index 000000000..294a166a5 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/SerializedCompletionSuites.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Testing + +/// A parent suite for completion-related test suites. +/// +/// This suite must serialize their execution against each other. Both +/// `CompletionScriptTests` and `DefaultAsFlagCompletionTests` invoke +/// `CompletionsGenerator`, which mutates the process-global +/// `CompletionShell._requesting` mutex during script generation. Swift +/// Testing's `.serialized` trait only prevents parallelism within a single +/// suite; nesting both suites under a `.serialized` parent extends the +/// guarantee across the two nested suites (and any tests they contain). +@Suite( + .serialized +) +enum SerializedCompletionSuites {}