Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
75 changes: 75 additions & 0 deletions Sources/Testing/Running/SkipInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -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
}
Expand Down
37 changes: 17 additions & 20 deletions Sources/Testing/Test+Cancellation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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<T>(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), skipInfo: SkipInfo) throws -> Never where T: TestCancellable {
private func _cancel<T>(_ 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.)
Expand Down Expand Up @@ -171,8 +168,6 @@ private func _cancel<T>(_ cancellableValue: T?, for testAndTestCase: (Test?, Tes
issue.record()
}
}

throw skipInfo
}

// MARK: - Test cancellation
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
50 changes: 48 additions & 2 deletions Tests/TestingTests/TestCancellationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -210,8 +258,6 @@
}

#if canImport(XCTest)
import XCTest

final class TestCancellationTests: XCTestCase {
func testCancellationFromBackgroundTask() async {
let testCancelled = expectation(description: "Test cancelled")
Expand Down