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..662cfcb8a 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,67 @@ 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 { + _xctSkipType != nil + } + + /// Attempt to create an instance of this type from an instance of XCTest's + /// `XCTSkip` error type. + /// + /// - 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? { + guard let _xctSkipType, type(of: error) == _xctSkipType else { + return nil + } + + let userInfo = error._userInfo as? [String: Any] ?? [:] + + 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 + } + } + + 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 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 sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil) + return SkipInfo(comment: comment, sourceContext: sourceContext) + } + /// Initialize an instance of this type from an arbitrary error. /// /// - Parameters: @@ -72,6 +138,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..4c1ebe748 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,50 @@ } } +#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 + await Test { + throw XCTSkip("Threw XCTSkip instead of SkipInfo") + }.run(configuration: configuration) + } eventHandler: { event, eventContext in + if case let .testCancelled(skipInfo) = event.kind { + withKnownIssue("Comment isn't transferred from XCTSkip (swift-corelibs-xctest-#511)") { + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } when: { + Self.usesCorelibsXCTest + } + } + } + } + + @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 { + withKnownIssue("Comment isn't transferred from XCTSkip (swift-corelibs-xctest-#511)") { + #expect(skipInfo.comment == "Threw XCTSkip instead of SkipInfo") + } when: { + Self.usesCorelibsXCTest + } + } + } + } +#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 +258,6 @@ } #if canImport(XCTest) -import XCTest - final class TestCancellationTests: XCTestCase { func testCancellationFromBackgroundTask() async { let testCancelled = expectation(description: "Test cancelled")