Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d467a80
Add experimental SPI to cancel a running test.
grynspan Aug 21, 2025
4dce6f8
Availability
grynspan Aug 21, 2025
367c707
Lower availability with a fallback implementation
grynspan Aug 21, 2025
576a478
Wrong logic to get old task (facepalm)
grynspan Aug 21, 2025
be6d9bd
Record issues that occur during testing
grynspan Aug 25, 2025
5cc0093
Catch CancellationError from within a test and treat it as cancelling…
grynspan Aug 25, 2025
2390885
Interop with task cancellation and CancellationError
grynspan Aug 25, 2025
d64d019
Incorporate my own feedback
grynspan Aug 26, 2025
5e6880a
Add more tests
grynspan Aug 26, 2025
344da94
More tests, refactor cancel() a bit
grynspan Aug 26, 2025
ef18699
Restore @spi attribute
grynspan Aug 26, 2025
f0b6e9e
Add new event kinds to Snapshot for Xcode 16 compatibility
grynspan Aug 26, 2025
87fcdd5
Make Xcode 16 report skipped tests where possible
grynspan Aug 26, 2025
b32e502
Lazily gather backtraces where possible
grynspan Aug 26, 2025
f5ad85a
Merge branch 'main' into jgrynspan/test-cancellation
grynspan Aug 26, 2025
7371b60
Deduplicate attachment JSON source location field
grynspan Aug 26, 2025
6650c9c
Fix duplicated comment
grynspan Aug 26, 2025
ef609ab
Fix typo
grynspan Aug 26, 2025
2da8459
Plumb through exit test support properly
grynspan Aug 26, 2025
69feacc
Documentation tweaks and testing Task.cancel() in an exit test
grynspan Aug 26, 2025
c2d0090
Make sure CancellationError is only treated as a skip during trait ev…
grynspan Aug 26, 2025
e2f518b
Remove some trys
grynspan Aug 27, 2025
9e7c137
Add comment explaining weird task group
grynspan Aug 27, 2025
3982afb
Add placeholder text to DocC bundle
grynspan Aug 27, 2025
33283d7
Add comments per @stmontgomery's feedback
grynspan Aug 27, 2025
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
8 changes: 0 additions & 8 deletions Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ extension ABI {
/// - Warning: Inline attachment content is not yet part of the JSON schema.
var _bytes: Bytes?

/// The source location where this attachment was created.
///
/// - Warning: Attachment source locations are not yet part of the JSON
/// schema.
var _sourceLocation: SourceLocation?

init(encoding attachment: borrowing Attachment<AnyAttachable>, in eventContext: borrowing Event.Context) {
path = attachment.fileSystemPath

Expand All @@ -55,8 +49,6 @@ extension ABI {
return Bytes(rawValue: [UInt8](bytes))
}
}

_sourceLocation = attachment.sourceLocation
}
}

Expand Down
49 changes: 48 additions & 1 deletion Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ extension ABI {
case issueRecorded
case valueAttached
case testCaseEnded
case testCaseCancelled = "_testCaseCancelled"
case testEnded
case testSkipped
case testCancelled = "_testCancelled"
case runEnded
}

Expand Down Expand Up @@ -64,6 +66,38 @@ extension ABI {
/// - Warning: Test cases are not yet part of the JSON schema.
var _testCase: EncodedTestCase<V>?

/// The comments the test author provided for this event, if any.
///
/// The value of this property contains the comments related to the primary
/// user action that caused this event to be generated.
///
/// Some kinds of events have additional associated comments. For example,
/// when using ``withKnownIssue(_:isIntermittent:sourceLocation:_:)``, there
/// can be separate comments for the "underlying" issue versus the known
/// issue matcher, and either can be `nil`. In such cases, the secondary
/// comment(s) are represented via a distinct property depending on the kind
/// of that event.
///
/// - Warning: Comments at this level are not yet part of the JSON schema.
var _comments: [String]?

/// A source location associated with this event, if any.
///
/// The value of this property represents the source location most closely
/// related to the primary user action that caused this event to be
/// generated.
///
/// Some kinds of events have additional associated source locations. For
/// example, when using ``withKnownIssue(_:isIntermittent:sourceLocation:_:)``,
/// there can be separate source locations for the "underlying" issue versus
/// the known issue matcher. In such cases, the secondary source location(s)
/// are represented via a distinct property depending on the kind of that
/// event.
///
/// - Warning: Source locations at this level of the JSON schema are not yet
/// part of said JSON schema.
var _sourceLocation: SourceLocation?

init?(encoding event: borrowing Event, in eventContext: borrowing Event.Context, messages: borrowing [Event.HumanReadableOutputRecorder.Message]) {
switch event.kind {
case .runStarted:
Expand All @@ -78,18 +112,31 @@ extension ABI {
case let .issueRecorded(recordedIssue):
kind = .issueRecorded
issue = EncodedIssue(encoding: recordedIssue, in: eventContext)
_comments = recordedIssue.comments.map(\.rawValue)
_sourceLocation = recordedIssue.sourceLocation
case let .valueAttached(attachment):
kind = .valueAttached
self.attachment = EncodedAttachment(encoding: attachment, in: eventContext)
_sourceLocation = attachment.sourceLocation
case .testCaseEnded:
if eventContext.test?.isParameterized == false {
return nil
}
kind = .testCaseEnded
case let .testCaseCancelled(skipInfo):
kind = .testCaseCancelled
_comments = Array(skipInfo.comment).map(\.rawValue)
_sourceLocation = skipInfo.sourceLocation
case .testEnded:
kind = .testEnded
case .testSkipped:
case let .testSkipped(skipInfo):
kind = .testSkipped
_comments = Array(skipInfo.comment).map(\.rawValue)
_sourceLocation = skipInfo.sourceLocation
case let .testCancelled(skipInfo):
kind = .testCancelled
_comments = Array(skipInfo.comment).map(\.rawValue)
_sourceLocation = skipInfo.sourceLocation
case .runEnded:
kind = .runEnded
default:
Expand Down
1 change: 1 addition & 0 deletions Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ add_library(Testing
Test.ID.Selection.swift
Test.ID.swift
Test.swift
Test+Cancellation.swift
Test+Discovery.swift
Test+Discovery+Legacy.swift
Test+Macro.swift
Expand Down
52 changes: 52 additions & 0 deletions Sources/Testing/Events/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ public struct Event: Sendable {
/// that was passed to the event handler along with this event.
case testCaseEnded

/// A test case was cancelled.
///
/// - Parameters:
/// - skipInfo: A ``SkipInfo`` with details about the cancelled test case.
///
/// This event is generated by a call to ``Test/Case/cancel(_:sourceLocation:)``.
///
/// The test case that was cancelled is contained in the ``Event/Context``
/// instance that was passed to the event handler along with this event.
@_spi(Experimental)
indirect case testCaseCancelled(_ skipInfo: SkipInfo)

/// An expectation was checked with `#expect()` or `#require()`.
///
/// - Parameters:
Expand Down Expand Up @@ -121,6 +133,18 @@ public struct Event: Sendable {
/// available from this event's ``Event/testID`` property.
indirect case testSkipped(_ skipInfo: SkipInfo)

/// A test was cancelled.
///
/// - Parameters:
/// - skipInfo: A ``SkipInfo`` with details about the cancelled test.
///
/// This event is generated by a call to ``Test/cancel(_:sourceLocation:)``.
///
/// The test that was cancelled is contained in the ``Event/Context``
/// instance that was passed to the event handler along with this event.
@_spi(Experimental)
indirect case testCancelled(_ skipInfo: SkipInfo)

/// A step in the runner plan ended.
///
/// - Parameters:
Expand Down Expand Up @@ -395,6 +419,18 @@ extension Event.Kind {
/// A test case ended.
case testCaseEnded

/// A test case was cancelled.
///
/// - Parameters:
/// - skipInfo: A ``SkipInfo`` with details about the cancelled test case.
///
/// This event is generated by a call to ``Test/Case/cancel(_:sourceLocation:)``.
///
/// The test case that was cancelled is contained in the ``Event/Context``
/// instance that was passed to the event handler along with this event.
@_spi(Experimental)
indirect case testCaseCancelled(_ skipInfo: SkipInfo)

/// An expectation was checked with `#expect()` or `#require()`.
///
/// - Parameters:
Expand Down Expand Up @@ -431,6 +467,18 @@ extension Event.Kind {
/// - skipInfo: A ``SkipInfo`` containing details about this skipped test.
indirect case testSkipped(_ skipInfo: SkipInfo)

/// A test was cancelled.
///
/// - Parameters:
/// - skipInfo: A ``SkipInfo`` with details about the cancelled test.
///
/// This event is generated by a call to ``Test/cancel(_:sourceLocation:)``.
///
/// The test that was cancelled is contained in the ``Event/Context``
/// instance that was passed to the event handler along with this event.
@_spi(Experimental)
indirect case testCancelled(_ skipInfo: SkipInfo)

/// A step in the runner plan ended.
///
/// - Parameters:
Expand Down Expand Up @@ -479,6 +527,8 @@ extension Event.Kind {
self = .testCaseStarted
case .testCaseEnded:
self = .testCaseEnded
case let .testCaseCancelled(skipInfo):
self = .testCaseCancelled(skipInfo)
case let .expectationChecked(expectation):
let expectationSnapshot = Expectation.Snapshot(snapshotting: expectation)
self = Snapshot.expectationChecked(expectationSnapshot)
Expand All @@ -490,6 +540,8 @@ extension Event.Kind {
self = .testEnded
case let .testSkipped(skipInfo):
self = .testSkipped(skipInfo)
case let .testCancelled(skipInfo):
self = .testCancelled(skipInfo)
case .planStepEnded:
self = .planStepEnded
case let .iterationEnded(index):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ extension Event {

/// The number of known issues recorded for the test.
var knownIssueCount = 0

/// Information about the cancellation of this test or test case.
var cancellationInfo: SkipInfo?
}

/// Data tracked on a per-test basis.
Expand Down Expand Up @@ -251,6 +254,7 @@ extension Event.HumanReadableOutputRecorder {
0
}
let test = eventContext.test
let testCase = eventContext.testCase
let keyPath = eventContext.keyPath
let testName = if let test {
if let displayName = test.displayName {
Expand Down Expand Up @@ -310,6 +314,9 @@ extension Event.HumanReadableOutputRecorder {
case .testCaseStarted:
context.testData[keyPath] = .init(startInstant: instant)

case let .testCancelled(skipInfo), let .testCaseCancelled(skipInfo):
context.testData[keyPath]?.cancellationInfo = skipInfo

default:
// These events do not manipulate the context structure.
break
Expand Down Expand Up @@ -404,21 +411,29 @@ extension Event.HumanReadableOutputRecorder {
} else {
""
}
return if issues.errorIssueCount > 0 {
CollectionOfOne(
Message(
symbol: .fail,
stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) failed after \(duration)\(issues.description)."
)
) + _formattedComments(for: test)
var cancellationComment = "."
let (symbol, verbed): (Event.Symbol, String)
if issues.errorIssueCount > 0 {
(symbol, verbed) = (.fail, "failed")
} else if !test.isParameterized, let cancellationInfo = testData.cancellationInfo {
if let comment = cancellationInfo.comment {
cancellationComment = ": \"\(comment.rawValue)\""
}
(symbol, verbed) = (.skip, "was cancelled")
} else {
[
Message(
symbol: .pass(knownIssueCount: issues.knownIssueCount),
stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) passed after \(duration)\(issues.description)."
)
]
(symbol, verbed) = (.pass(knownIssueCount: issues.knownIssueCount), "passed")
}

var result = [
Message(
symbol: symbol,
stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) \(verbed) after \(duration)\(issues.description)\(cancellationComment)"
)
]
if issues.errorIssueCount > 0 {
result += _formattedComments(for: test)
}
return result

case let .testSkipped(skipInfo):
let test = test!
Expand All @@ -443,7 +458,7 @@ extension Event.HumanReadableOutputRecorder {
} else {
0
}
let labeledArguments = if let testCase = eventContext.testCase {
let labeledArguments = if let testCase {
testCase.labeledArguments()
} else {
""
Expand Down Expand Up @@ -523,7 +538,7 @@ extension Event.HumanReadableOutputRecorder {
return result

case .testCaseStarted:
guard let testCase = eventContext.testCase, testCase.isParameterized, let arguments = testCase.arguments else {
guard let testCase, testCase.isParameterized, let arguments = testCase.arguments else {
break
}

Expand All @@ -535,7 +550,7 @@ extension Event.HumanReadableOutputRecorder {
]

case .testCaseEnded:
guard verbosity > 0, let testCase = eventContext.testCase, testCase.isParameterized, let arguments = testCase.arguments else {
guard verbosity > 0, let test, let testCase, testCase.isParameterized, let arguments = testCase.arguments else {
break
}

Expand All @@ -544,18 +559,28 @@ extension Event.HumanReadableOutputRecorder {
let issues = _issueCounts(in: testDataGraph)
let duration = testData.startInstant.descriptionOfDuration(to: instant)

let message = if issues.errorIssueCount > 0 {
Message(
symbol: .fail,
stringValue: "Test case passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName) failed after \(duration)\(issues.description)."
)
var cancellationComment = "."
let (symbol, verbed): (Event.Symbol, String)
if issues.errorIssueCount > 0 {
(symbol, verbed) = (.fail, "failed")
} else if !test.isParameterized, let cancellationInfo = testData.cancellationInfo {
if let comment = cancellationInfo.comment {
cancellationComment = ": \"\(comment.rawValue)\""
}
(symbol, verbed) = (.skip, "was cancelled")
} else {
(symbol, verbed) = (.pass(knownIssueCount: issues.knownIssueCount), "passed")
}
return [
Message(
symbol: .pass(knownIssueCount: issues.knownIssueCount),
stringValue: "Test case passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName) passed after \(duration)\(issues.description)."
symbol: symbol,
stringValue: "Test case passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName) \(verbed) after \(duration)\(issues.description)\(cancellationComment)"
)
}
return [message]
]

case .testCancelled, .testCaseCancelled:
// Handled in .testEnded and .testCaseEnded
break

case let .iterationEnded(index):
guard let iterationStartInstant = context.iterationStartInstant else {
Expand Down
Loading