From adddada75f2fe0da73260bfa010fe53d226a9593 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 3 Sep 2025 18:45:58 -0400 Subject: [PATCH 1/6] [EXPERIMENTAL] `XCTSkip` interop with test cancellation This PR adds a small amount of interop between Swift Testing and XCTest so that, if a test throws an error of type `XCTSkip`, it is treated as if the test were cancelled. Additional changes will be needed in XCTest and corelibs-xctest to fully implement this functionality. --- Sources/Testing/ExitTests/ExitTest.swift | 4 +- Sources/Testing/Running/SkipInfo.swift | 82 +++++++++++++++++++ Sources/Testing/Test+Cancellation.swift | 37 ++++----- .../TestingTests/TestCancellationTests.swift | 21 ++++- 4 files changed, 120 insertions(+), 24 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 6f93a470c..945c7c4ae 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -1076,9 +1076,9 @@ extension ExitTest { } else if let attachment = event.attachment { Attachment.record(attachment, sourceLocation: event._sourceLocation!) } else if case .testCancelled = event.kind { - _ = try? Test.cancel(with: skipInfo) + Test.cancel(with: skipInfo) } else if case .testCaseCancelled = event.kind { - _ = try? Test.Case.cancel(with: skipInfo) + Test.Case.cancel(with: skipInfo) } } diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index 687cf8434..29aca3ebc 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -8,6 +8,11 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +#if _runtime(_ObjC) && canImport(Foundation) +private import ObjectiveC +private import Foundation +#endif + /// A type representing the details of a skipped test. @_spi(ForToolsIntegrationOnly) public struct SkipInfo: Sendable { @@ -57,6 +62,74 @@ extension SkipInfo: Codable {} // MARK: - extension SkipInfo { + /// Whether or not we can create an instance of ``SkipInfo`` from an instance + /// of XCTest's `XCTSkip` type. + static var isXCTSkipInteropEnabled: Bool { + _XCTSkipCopyInfoDictionary != nil + } + + /// Gather the properties of an instance of `XCTSkip` that we can use to + /// construct an instance of ``SkipInfo``. + private static let _XCTSkipCopyInfoDictionary: (@convention(c) (Unmanaged) -> Unmanaged?)? = { +#if !SWT_NO_DYNAMIC_LINKING + // Check if XCTest exports a function for us to invoke to get the bits of + // the XCTSkip error we need. If so, call it and extract them. + var result = symbol(named: "_XCTSkipCopyInfoDictionary").map { + castCFunction(at: $0, to: (@convention(c) (Unmanaged) -> Unmanaged?).self) + } + if let result { + return result + } + +#if _runtime(_ObjC) && canImport(Foundation) + if result == nil { + // Temporary workaround that allows us to implement XCTSkip bridging on + // Apple platforms where XCTest does not export the necessary function. + return { errorAddress in + guard let error = errorAddress.takeUnretainedValue() as? any Error, + error._domain == "com.apple.XCTestErrorDomain" && error._code == 106, + let userInfo = error._userInfo as? [String: Any], + let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject else { + return nil + } + + var result = [String: Any]() + result["XCTSkipMessage"] = skippedContext.value(forKey: "message") + result["XCTSkipCallStack"] = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") + return .passRetained(result as AnyObject) + } + } +#endif + + return result +#else + return nil +#endif + }() + + /// Attempt to create an instance of this type from an instance of XCTest's + /// `XCTSkip` error type or its Objective-C equivalent. + /// + /// - Parameters: + /// - error: The error that may be an instance of `XCTSkip`. + /// + /// - Returns: An instance of ``SkipInfo`` corresponding to `error`, or `nil` + /// if `error` was not an instance of `XCTSkip`. + private static func _fromXCTSkip(_ error: any Error) -> Self? { + let errorObject = error as AnyObject + if let info = _XCTSkipCopyInfoDictionary?(.passUnretained(errorObject))?.takeRetainedValue() as? [String: Any] { + let comment = (info["XCTSkipMessage"] as? String).map { Comment(rawValue: $0) } + let backtrace = (info["XCTSkipCallStack"] as? [UInt64]).map { Backtrace(addresses: $0) } + let sourceContext = SourceContext( + backtrace: backtrace ?? Backtrace(forFirstThrowOf: error), + sourceLocation: nil + ) + return SkipInfo(comment: comment, sourceContext: sourceContext) + } + + return nil + } + /// Initialize an instance of this type from an arbitrary error. /// /// - Parameters: @@ -72,6 +145,15 @@ extension SkipInfo { let backtrace = Backtrace(forFirstThrowOf: error) let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil) self.init(comment: nil, sourceContext: sourceContext) + } else if let skipInfo = Self._fromXCTSkip(error) { + // XCTSkip doesn't cancel the current test or task for us, so we do it + // here as part of the bridging process. + self = skipInfo + if Test.Case.current != nil { + Test.Case.cancel(with: skipInfo) + } else if Test.current != nil { + Test.cancel(with: skipInfo) + } } else { return nil } diff --git a/Sources/Testing/Test+Cancellation.swift b/Sources/Testing/Test+Cancellation.swift index ed8738a64..0fcbd6887 100644 --- a/Sources/Testing/Test+Cancellation.swift +++ b/Sources/Testing/Test+Cancellation.swift @@ -18,13 +18,12 @@ protocol TestCancellable: Sendable { /// - Parameters: /// - skipInfo: Information about the cancellation event. /// - /// - Throws: An error indicating that the current instance of this type has - /// been cancelled. - /// /// Note that the public ``Test/cancel(_:sourceLocation:)`` function has a /// different signature and accepts a source location rather than a source /// context value. - static func cancel(with skipInfo: SkipInfo) throws -> Never + /// + /// The caller is responsible for throwing `skipInfo` where needed. + static func cancel(with skipInfo: SkipInfo) /// Make an instance of ``Event/Kind`` appropriate for an instance of this /// type. @@ -110,7 +109,7 @@ extension TestCancellable { // associated with it. let skipInfo = _currentSkipInfo ?? SkipInfo(sourceContext: SourceContext(backtrace: .current(), sourceLocation: nil)) - _ = try? Self.cancel(with: skipInfo) + Self.cancel(with: skipInfo) } } } @@ -125,9 +124,7 @@ extension TestCancellable { /// is set and we need fallback handling. /// - testAndTestCase: The test and test case to use when posting an event. /// - skipInfo: Information about the cancellation event. -/// -/// - Throws: An instance of ``SkipInfo`` describing the cancellation. -private func _cancel(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), skipInfo: SkipInfo) throws -> Never where T: TestCancellable { +private func _cancel(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), skipInfo: SkipInfo) where T: TestCancellable { if cancellableValue != nil { // If the current test case is still running, take its task property (which // signals to subsequent callers that it has been cancelled.) @@ -171,8 +168,6 @@ private func _cancel(_ cancellableValue: T?, for testAndTestCase: (Test?, Tes issue.record() } } - - throw skipInfo } // MARK: - Test cancellation @@ -223,12 +218,13 @@ extension Test: TestCancellable { @_spi(Experimental) public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never { let skipInfo = SkipInfo(comment: comment, sourceContext: SourceContext(backtrace: nil, sourceLocation: sourceLocation)) - try Self.cancel(with: skipInfo) + Self.cancel(with: skipInfo) + throw skipInfo } - static func cancel(with skipInfo: SkipInfo) throws -> Never { + static func cancel(with skipInfo: SkipInfo) { let test = Test.current - try _cancel(test, for: (test, nil), skipInfo: skipInfo) + _cancel(test, for: (test, nil), skipInfo: skipInfo) } static func makeCancelledEventKind(with skipInfo: SkipInfo) -> Event.Kind { @@ -284,19 +280,20 @@ extension Test.Case: TestCancellable { @_spi(Experimental) public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never { let skipInfo = SkipInfo(comment: comment, sourceContext: SourceContext(backtrace: nil, sourceLocation: sourceLocation)) - try Self.cancel(with: skipInfo) + Self.cancel(with: skipInfo) + throw skipInfo } - static func cancel(with skipInfo: SkipInfo) throws -> Never { + static func cancel(with skipInfo: SkipInfo) { let test = Test.current let testCase = Test.Case.current - do { - // Cancel the current test case (if it's nil, that's the API misuse path.) - try _cancel(testCase, for: (test, testCase), skipInfo: skipInfo) - } catch _ where test?.isParameterized == false { + // Cancel the current test case (if it's nil, that's the API misuse path.) + _cancel(testCase, for: (test, testCase), skipInfo: skipInfo) + + if let test, !test.isParameterized { // The current test is not parameterized, so cancel the whole test too. - try _cancel(test, for: (test, nil), skipInfo: skipInfo) + _cancel(test, for: (test, nil), skipInfo: skipInfo) } } diff --git a/Tests/TestingTests/TestCancellationTests.swift b/Tests/TestingTests/TestCancellationTests.swift index a4f95fe56..43489d138 100644 --- a/Tests/TestingTests/TestCancellationTests.swift +++ b/Tests/TestingTests/TestCancellationTests.swift @@ -10,6 +10,10 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +#if canImport(XCTest) +import XCTest +#endif + @Suite(.serialized) struct `Test cancellation tests` { func testCancellation( testCancelled: Int = 0, @@ -170,6 +174,21 @@ } } +#if canImport(XCTest) + @Test(.enabled(if: SkipInfo.isXCTSkipInteropEnabled)) + func `Cancelling a test with XCTSkip`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + throw XCTSkip("Threw XCTSkip instead of SkipInfo") + }.run(configuration: configuration) + } eventHandler: { event, eventContext in + if case let .testCancelled(skipInfo) = event.kind { + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } + } + } +#endif + #if !SWT_NO_EXIT_TESTS @Test func `Cancelling the current test from within an exit test`() async { await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in @@ -210,8 +229,6 @@ } #if canImport(XCTest) -import XCTest - final class TestCancellationTests: XCTestCase { func testCancellationFromBackgroundTask() async { let testCancelled = expectation(description: "Test cancelled") From 83de0a357dc3ad138763730a98660f9784cc3cb6 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 4 Sep 2025 10:08:19 -0400 Subject: [PATCH 2/6] Refactor to improve compatibility --- Sources/Testing/Running/SkipInfo.swift | 102 +++++++++--------- .../TestingTests/TestCancellationTests.swift | 14 +++ 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index 29aca3ebc..915f25007 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -62,53 +62,18 @@ extension SkipInfo: Codable {} // MARK: - extension SkipInfo { + /// The Swift type corresponding to `XCTSkip` if XCTest has been linked into + /// the current process. + private static let _xctSkipType: Any.Type? = _typeByName("6XCTest7XCTSkipV") // _mangledTypeName(XCTest.XCTSkip.self) + /// Whether or not we can create an instance of ``SkipInfo`` from an instance /// of XCTest's `XCTSkip` type. static var isXCTSkipInteropEnabled: Bool { - _XCTSkipCopyInfoDictionary != nil + _xctSkipType != nil } - /// Gather the properties of an instance of `XCTSkip` that we can use to - /// construct an instance of ``SkipInfo``. - private static let _XCTSkipCopyInfoDictionary: (@convention(c) (Unmanaged) -> Unmanaged?)? = { -#if !SWT_NO_DYNAMIC_LINKING - // Check if XCTest exports a function for us to invoke to get the bits of - // the XCTSkip error we need. If so, call it and extract them. - var result = symbol(named: "_XCTSkipCopyInfoDictionary").map { - castCFunction(at: $0, to: (@convention(c) (Unmanaged) -> Unmanaged?).self) - } - if let result { - return result - } - -#if _runtime(_ObjC) && canImport(Foundation) - if result == nil { - // Temporary workaround that allows us to implement XCTSkip bridging on - // Apple platforms where XCTest does not export the necessary function. - return { errorAddress in - guard let error = errorAddress.takeUnretainedValue() as? any Error, - error._domain == "com.apple.XCTestErrorDomain" && error._code == 106, - let userInfo = error._userInfo as? [String: Any], - let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject else { - return nil - } - - var result = [String: Any]() - result["XCTSkipMessage"] = skippedContext.value(forKey: "message") - result["XCTSkipCallStack"] = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") - return .passRetained(result as AnyObject) - } - } -#endif - - return result -#else - return nil -#endif - }() - /// Attempt to create an instance of this type from an instance of XCTest's - /// `XCTSkip` error type or its Objective-C equivalent. + /// `XCTSkip` error type. /// /// - Parameters: /// - error: The error that may be an instance of `XCTSkip`. @@ -116,18 +81,53 @@ extension SkipInfo { /// - Returns: An instance of ``SkipInfo`` corresponding to `error`, or `nil` /// if `error` was not an instance of `XCTSkip`. private static func _fromXCTSkip(_ error: any Error) -> Self? { - let errorObject = error as AnyObject - if let info = _XCTSkipCopyInfoDictionary?(.passUnretained(errorObject))?.takeRetainedValue() as? [String: Any] { - let comment = (info["XCTSkipMessage"] as? String).map { Comment(rawValue: $0) } - let backtrace = (info["XCTSkipCallStack"] as? [UInt64]).map { Backtrace(addresses: $0) } - let sourceContext = SourceContext( - backtrace: backtrace ?? Backtrace(forFirstThrowOf: error), - sourceLocation: nil - ) - return SkipInfo(comment: comment, sourceContext: sourceContext) + guard let _xctSkipType, type(of: error) == _xctSkipType else { + return nil + } + + let userInfo = error._userInfo as? [String: Any] ?? [:] + + var message = userInfo["XCTestErrorUserInfoKeyMessage"] as? String + var explanation = userInfo["XCTestErrorUserInfoKeyExplanation"] as? String + var callStackAddresses = userInfo["XCTestErrorUserInfoKeyCallStackAddresses"] as? [UInt64] + let sourceLocation: SourceLocation? = (userInfo["XCTestErrorUserInfoKeySourceLocation"] as? [String: Any]).flatMap { sourceLocation in + guard let fileID = sourceLocation["fileID"] as? String, + let filePath = sourceLocation["filePath"] as? String, + let line = sourceLocation["line"] as? Int, + let column = sourceLocation["column"] as? Int else { + return nil + } + return SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + } + +#if _runtime(_ObjC) && canImport(Foundation) + // Temporary workaround that allows us to implement XCTSkip bridging on + // Apple platforms where XCTest does not provide the user info values above. + if message == nil && explanation == nil && callStackAddresses == nil, + let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject { + message = skippedContext.value(forKey: "message") as? String + explanation = skippedContext.value(forKey: "explanation") as? String + callStackAddresses = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") as? [UInt64] + } +#endif + + let comment: Comment? = switch (message, explanation) { + case let (.some(message), .some(explanation)): + "\(message) - \(explanation)" + case let (_, .some(comment)), let (.some(comment), _): + Comment(rawValue: comment) + default: + nil + } + let backtrace: Backtrace? = callStackAddresses.map { callStackAddresses in + Backtrace(addresses: callStackAddresses) } - return nil + let sourceContext = SourceContext( + backtrace: backtrace ?? Backtrace(forFirstThrowOf: error), + sourceLocation: sourceLocation + ) + return SkipInfo(comment: comment, sourceContext: sourceContext) } /// Initialize an instance of this type from an arbitrary error. diff --git a/Tests/TestingTests/TestCancellationTests.swift b/Tests/TestingTests/TestCancellationTests.swift index 43489d138..25845ecd6 100644 --- a/Tests/TestingTests/TestCancellationTests.swift +++ b/Tests/TestingTests/TestCancellationTests.swift @@ -187,6 +187,20 @@ import XCTest } } } + + @Test(.enabled(if: SkipInfo.isXCTSkipInteropEnabled)) + func `Cancelling a test with XCTSkipIf`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + try XCTSkipIf(1 > 0, "Threw XCTSkip instead of SkipInfo") + }.run(configuration: configuration) + } eventHandler: { event, eventContext in + if case let .testCancelled(skipInfo) = event.kind { + print(skipInfo) + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } + } + } #endif #if !SWT_NO_EXIT_TESTS From 0825a408b3c75bc5516a74bcfc2fcd762b4611f7 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 4 Sep 2025 11:08:35 -0400 Subject: [PATCH 3/6] Adjust Apple fallback path --- Sources/Testing/Running/SkipInfo.swift | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index 915f25007..f637bdff5 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -88,7 +88,6 @@ extension SkipInfo { let userInfo = error._userInfo as? [String: Any] ?? [:] var message = userInfo["XCTestErrorUserInfoKeyMessage"] as? String - var explanation = userInfo["XCTestErrorUserInfoKeyExplanation"] as? String var callStackAddresses = userInfo["XCTestErrorUserInfoKeyCallStackAddresses"] as? [UInt64] let sourceLocation: SourceLocation? = (userInfo["XCTestErrorUserInfoKeySourceLocation"] as? [String: Any]).flatMap { sourceLocation in guard let fileID = sourceLocation["fileID"] as? String, @@ -103,26 +102,15 @@ extension SkipInfo { #if _runtime(_ObjC) && canImport(Foundation) // Temporary workaround that allows us to implement XCTSkip bridging on // Apple platforms where XCTest does not provide the user info values above. - if message == nil && explanation == nil && callStackAddresses == nil, + if message == nil && callStackAddresses == nil, let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject { message = skippedContext.value(forKey: "message") as? String - explanation = skippedContext.value(forKey: "explanation") as? String callStackAddresses = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") as? [UInt64] } #endif - let comment: Comment? = switch (message, explanation) { - case let (.some(message), .some(explanation)): - "\(message) - \(explanation)" - case let (_, .some(comment)), let (.some(comment), _): - Comment(rawValue: comment) - default: - nil - } - let backtrace: Backtrace? = callStackAddresses.map { callStackAddresses in - Backtrace(addresses: callStackAddresses) - } - + let comment: Comment? = message.map(Comment.init(rawValue:)) + let backtrace: Backtrace? = callStackAddresses.map(Backtrace.init(addresses:)) let sourceContext = SourceContext( backtrace: backtrace ?? Backtrace(forFirstThrowOf: error), sourceLocation: sourceLocation From dba0a81732c0e886cda0fa85989a90d6db9b9330 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 4 Sep 2025 11:29:18 -0400 Subject: [PATCH 4/6] Add XFAIL for comment until corelibs-xctest is updated --- .../TestingTests/TestCancellationTests.swift | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Tests/TestingTests/TestCancellationTests.swift b/Tests/TestingTests/TestCancellationTests.swift index 25845ecd6..044965ebb 100644 --- a/Tests/TestingTests/TestCancellationTests.swift +++ b/Tests/TestingTests/TestCancellationTests.swift @@ -175,6 +175,14 @@ import XCTest } #if canImport(XCTest) + static var usesCorelibsXCTest: Bool { +#if SWT_TARGET_OS_APPLE + false +#else + true +#endif + } + @Test(.enabled(if: SkipInfo.isXCTSkipInteropEnabled)) func `Cancelling a test with XCTSkip`() async { await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in @@ -183,7 +191,11 @@ import XCTest }.run(configuration: configuration) } eventHandler: { event, eventContext in if case let .testCancelled(skipInfo) = event.kind { - #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + withKnownIssue("Comment isn't transferred from XCTSkip") { + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } when: { + Self.usesCorelibsXCTest + } } } } @@ -196,8 +208,11 @@ import XCTest }.run(configuration: configuration) } eventHandler: { event, eventContext in if case let .testCancelled(skipInfo) = event.kind { - print(skipInfo) - #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + withKnownIssue("Comment isn't transferred from XCTSkip") { + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } when: { + Self.usesCorelibsXCTest + } } } } From 84685f825f46a4173341a97c7485f1a62224099d Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 4 Sep 2025 11:31:27 -0400 Subject: [PATCH 5/6] Reference corelibs issue --- Tests/TestingTests/TestCancellationTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/TestingTests/TestCancellationTests.swift b/Tests/TestingTests/TestCancellationTests.swift index 044965ebb..4c1ebe748 100644 --- a/Tests/TestingTests/TestCancellationTests.swift +++ b/Tests/TestingTests/TestCancellationTests.swift @@ -191,7 +191,7 @@ import XCTest }.run(configuration: configuration) } eventHandler: { event, eventContext in if case let .testCancelled(skipInfo) = event.kind { - withKnownIssue("Comment isn't transferred from XCTSkip") { + withKnownIssue("Comment isn't transferred from XCTSkip (swift-corelibs-xctest-#511)") { #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") } when: { Self.usesCorelibsXCTest @@ -208,7 +208,7 @@ import XCTest }.run(configuration: configuration) } eventHandler: { event, eventContext in if case let .testCancelled(skipInfo) = event.kind { - withKnownIssue("Comment isn't transferred from XCTSkip") { + withKnownIssue("Comment isn't transferred from XCTSkip (swift-corelibs-xctest-#511)") { #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") } when: { Self.usesCorelibsXCTest From 9cbbdf3b0e9f76c96f5dcea323420f923e0c980b Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 4 Sep 2025 18:34:18 -0400 Subject: [PATCH 6/6] Decode the SkipInfo from a JSON blob instead of passing around individual fields --- Sources/Testing/Running/SkipInfo.swift | 43 ++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index f637bdff5..662cfcb8a 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -87,34 +87,39 @@ extension SkipInfo { let userInfo = error._userInfo as? [String: Any] ?? [:] - var message = userInfo["XCTestErrorUserInfoKeyMessage"] as? String - var callStackAddresses = userInfo["XCTestErrorUserInfoKeyCallStackAddresses"] as? [UInt64] - let sourceLocation: SourceLocation? = (userInfo["XCTestErrorUserInfoKeySourceLocation"] as? [String: Any]).flatMap { sourceLocation in - guard let fileID = sourceLocation["fileID"] as? String, - let filePath = sourceLocation["filePath"] as? String, - let line = sourceLocation["line"] as? Int, - let column = sourceLocation["column"] as? Int else { - return nil + if let skipInfoJSON = userInfo["XCTestErrorUserInfoKeyBridgedJSONRepresentation"] as? any RandomAccessCollection { + func open(_ skipInfoJSON: some RandomAccessCollection) -> Self? { + try? skipInfoJSON.withContiguousStorageIfAvailable { skipInfoJSON in + try JSON.decode(Self.self, from: UnsafeRawBufferPointer(skipInfoJSON)) + } + } + if let skipInfo = open(skipInfoJSON) { + return skipInfo } - return SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) } + var comment: Comment? + var backtrace: Backtrace? #if _runtime(_ObjC) && canImport(Foundation) // Temporary workaround that allows us to implement XCTSkip bridging on // Apple platforms where XCTest does not provide the user info values above. - if message == nil && callStackAddresses == nil, - let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject { - message = skippedContext.value(forKey: "message") as? String - callStackAddresses = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") as? [UInt64] + if let skippedContext = userInfo["XCTestErrorUserInfoKeySkippedTestContext"] as? NSObject { + if let message = skippedContext.value(forKey: "message") as? String { + comment = Comment.init(rawValue: message) + } + if let callStackAddresses = skippedContext.value(forKeyPath: "sourceCodeContext.callStack.address") as? [UInt64] { + backtrace = Backtrace(addresses: callStackAddresses) + } } +#else + // On non-Apple platforms, we just don't have this information. + // SEE: swift-corelibs-xctest-#511 #endif + if backtrace == nil { + backtrace = Backtrace(forFirstThrowOf: error) + } - let comment: Comment? = message.map(Comment.init(rawValue:)) - let backtrace: Backtrace? = callStackAddresses.map(Backtrace.init(addresses:)) - let sourceContext = SourceContext( - backtrace: backtrace ?? Backtrace(forFirstThrowOf: error), - sourceLocation: sourceLocation - ) + let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil) return SkipInfo(comment: comment, sourceContext: sourceContext) }