Skip to content

Commit f03cc98

Browse files
Implement swift test --xunit-output. (#108)
* Implement `swift test --xunit-output`. This PR implements the `--xunit-output` option when using SwiftPM. Note that internally, the code refers to JUnit rather than xUnit: that's because the format produced by XCTest and SwiftPM today does not _not_ conform to the xUnit schema(s) described at https://www.xunit.org, but rather are a variant of the (schema-less?) JUnit format. This change renames `Event.Recorder` to `Event.ConsoleOutputRecorder` and adds `Event.JUnitXMLRecorder`. It adds a protocol, `EventRecorder` (remember that protocols cannot be nested in types, so no dot) to which both of these types conform, although that's a formality only as we are not doing anything generic over recorder instances. If the user specifies `--xunit-output` when calling into `swift test`, we open a file (using good ol' `fopen()`.) Our first official use of a non-copyable type is here (hooray!) as we use one to box the resulting file handle and ensure we call `fclose()` after tests finish running. We do **not** use Foundation's `FileHandle` for this purpose because, although it has the right ownership semantics, it does not expose API for creating _new_ files, only for opening existing ones. `fopen()` can of course fail, so we now need to potentially throw a C error code. I've added an internal `CError` type to represent them. We're not using `POSIXError` here because it's a Foundation dependency, but also because it's possible for `errno` to be set to a value that is not representable as an instance of `POSIXErrorCode`, which is a closed enumeration. Resolves rdar://118199250. --------- Co-authored-by: Stuart Montgomery <smontgomery@apple.com>
1 parent bbf0133 commit f03cc98

File tree

13 files changed

+492
-53
lines changed

13 files changed

+492
-53
lines changed

Sources/Testing/Events/Clock.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ extension Test.Clock.Instant {
9393
public var durationSince1970: Duration {
9494
Duration(wall)
9595
}
96+
97+
/// Get the number of nanoseconds from this instance to another.
98+
///
99+
/// - Parameters:
100+
/// - other: The later instant.
101+
///
102+
/// - Returns: The number of nanoseconds between `self` and `other`. If
103+
/// `other` is ordered before this instance, the result is negative.
104+
func nanoseconds(until other: Self) -> Int64 {
105+
if other < self {
106+
return -other.nanoseconds(until: self)
107+
}
108+
let otherNanoseconds = (other.suspending.seconds * 1_000_000_000) + (other.suspending.attoseconds / 1_000_000_000)
109+
let selfNanoseconds = (suspending.seconds * 1_000_000_000) + (suspending.attoseconds / 1_000_000_000)
110+
return otherNanoseconds - selfNanoseconds
111+
}
96112
}
97113
#endif
98114

@@ -209,9 +225,7 @@ extension Test.Clock.Instant {
209225
/// up to millisecond accuracy.
210226
func descriptionOfDuration(to other: Test.Clock.Instant) -> String {
211227
#if SWT_TARGET_OS_APPLE
212-
let otherNanoseconds = (other.suspending.seconds * 1_000_000_000) + (other.suspending.attoseconds / 1_000_000_000)
213-
let selfNanoseconds = (suspending.seconds * 1_000_000_000) + (suspending.attoseconds / 1_000_000_000)
214-
let (seconds, nanosecondsRemaining) = (otherNanoseconds - selfNanoseconds).quotientAndRemainder(dividingBy: 1_000_000_000)
228+
let (seconds, nanosecondsRemaining) = nanoseconds(until: other).quotientAndRemainder(dividingBy: 1_000_000_000)
215229
return String(describing: TimeValue((seconds, nanosecondsRemaining * 1_000_000_000)))
216230
#else
217231
return String(describing: TimeValue(Duration(other.suspending) - Duration(suspending)))

Sources/Testing/Events/Event.Recorder.swift renamed to Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ extension Event {
1313
/// them as human-readable strings.
1414
///
1515
/// The format of the output is not meant to be machine-readable and is
16-
/// subject to change.
17-
public struct Recorder: Sendable {
16+
/// subject to change. For machine-readable output, use ``JUnitXMLRecorder``.
17+
public struct ConsoleOutputRecorder: Sendable {
1818
/// An enumeration describing options to use when writing events to a
1919
/// stream.
2020
public enum Option: Sendable {
@@ -96,8 +96,8 @@ extension Event {
9696
var write: @Sendable (String) -> Void
9797

9898
/// A type that contains mutable context for
99-
/// ``Event/write(using:options:context:)``.
100-
fileprivate struct Context {
99+
/// ``Event/ConsoleOutputRecorder``.
100+
private struct _Context {
101101
/// The instant at which the run started.
102102
var runStartInstant: Test.Clock.Instant?
103103

@@ -112,7 +112,7 @@ extension Event {
112112
/// A type describing data tracked on a per-test basis.
113113
struct TestData {
114114
/// The instant at which the test started.
115-
var startInstant: Test.Clock.Instant = .now
115+
var startInstant: Test.Clock.Instant
116116

117117
/// The number of issues recorded for the test.
118118
var issueCount = 0
@@ -127,7 +127,7 @@ extension Event {
127127

128128
/// This event recorder's mutable context about events it has received,
129129
/// which may be used to inform how subsequent events are written.
130-
@Locked private var context = Context()
130+
@Locked private var _context = _Context()
131131

132132
/// Initialize a new event recorder.
133133
///
@@ -153,7 +153,7 @@ extension Event {
153153

154154
// MARK: - Equatable, Hashable
155155

156-
extension Event.Recorder.Option: Equatable, Hashable {}
156+
extension Event.ConsoleOutputRecorder.Option: Equatable, Hashable {}
157157

158158
// MARK: -
159159

@@ -163,7 +163,7 @@ private let _ansiEscapeCodePrefix = "\u{001B}["
163163
/// The ANSI escape code to reset text output to default settings.
164164
private let _resetANSIEscapeCode = "\(_ansiEscapeCodePrefix)0m"
165165

166-
extension Event.Recorder {
166+
extension Event.ConsoleOutputRecorder {
167167
/// An enumeration describing the symbols used as prefixes when writing
168168
/// output.
169169
fileprivate enum Symbol {
@@ -286,7 +286,7 @@ extension Event.Recorder {
286286
///
287287
/// - Returns: A string representation of `self` appropriate for writing to
288288
/// a stream.
289-
func stringValue(options: Set<Event.Recorder.Option>) -> String {
289+
func stringValue(options: Set<Event.ConsoleOutputRecorder.Option>) -> String {
290290
var symbolCharacter = String(_unicodeCharacter)
291291
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
292292
if options.contains(.useSFSymbols) {
@@ -324,7 +324,7 @@ extension Event.Recorder {
324324
///
325325
/// - Returns: A formatted string representing `comments`, or `nil` if there
326326
/// are none.
327-
private func _formattedComments(_ comments: [Comment], options: Set<Event.Recorder.Option>) -> String? {
327+
private func _formattedComments(_ comments: [Comment], options: Set<Event.ConsoleOutputRecorder.Option>) -> String? {
328328
if comments.isEmpty {
329329
return nil
330330
}
@@ -368,7 +368,7 @@ extension Event.Recorder {
368368
///
369369
/// - Returns: A formatted string representing the comments attached to `test`,
370370
/// or `nil` if there are none.
371-
private func _formattedComments(for test: Test, options: Set<Event.Recorder.Option>) -> String? {
371+
private func _formattedComments(for test: Test, options: Set<Event.ConsoleOutputRecorder.Option>) -> String? {
372372
_formattedComments(test.comments(from: Comment.self), options: options)
373373
}
374374

@@ -379,7 +379,7 @@ extension Event.Recorder {
379379
/// - graph: The graph to walk while counting issues.
380380
///
381381
/// - Returns: A tuple containing the number of issues recorded in `graph`.
382-
private func _issueCounts(in graph: Graph<String, Event.Recorder.Context.TestData?>?) -> (issueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) {
382+
private func _issueCounts(in graph: Graph<String, Event.ConsoleOutputRecorder._Context.TestData?>?) -> (issueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) {
383383
guard let graph else {
384384
return (0, 0, 0, "")
385385
}
@@ -412,7 +412,7 @@ extension Tag.Color {
412412
/// - Returns: The corresponding ANSI escape code. If the
413413
/// ``Event/Recorder/Option/useANSIEscapeCodes`` option is not specified,
414414
/// returns `nil`.
415-
fileprivate func ansiEscapeCode(options: Set<Event.Recorder.Option>) -> String? {
415+
fileprivate func ansiEscapeCode(options: Set<Event.ConsoleOutputRecorder.Option>) -> String? {
416416
guard options.contains(.useANSIEscapeCodes) else {
417417
return nil
418418
}
@@ -471,7 +471,7 @@ extension Test.Case {
471471

472472
// MARK: -
473473

474-
extension Event.Recorder {
474+
extension Event.ConsoleOutputRecorder: EventRecorder {
475475
/// Generate a printable string describing the colors of a set of tags
476476
/// suitable for display in test output.
477477
///
@@ -509,7 +509,7 @@ extension Event.Recorder {
509509
///
510510
/// - Returns: A string description of the event, or `nil` if there is nothing
511511
/// useful to output for this event.
512-
func _record(_ event: borrowing Event, in eventContext: borrowing Event.Context) -> String? {
512+
private func _record(_ event: borrowing Event, in eventContext: borrowing Event.Context) -> String? {
513513
let test = eventContext.test
514514
var testName: String
515515
if let displayName = test?.displayName {
@@ -529,7 +529,7 @@ extension Event.Recorder {
529529

530530
switch event.kind {
531531
case .runStarted:
532-
$context.withLock { context in
532+
$_context.withLock { context in
533533
context.runStartInstant = instant
534534
}
535535
let symbol = Symbol.default.stringValue(options: options)
@@ -556,8 +556,8 @@ extension Event.Recorder {
556556

557557
case .testStarted:
558558
let test = test!
559-
$context.withLock { context in
560-
context.testData[test.id.keyPathRepresentation] = .init()
559+
$_context.withLock { context in
560+
context.testData[test.id.keyPathRepresentation] = .init(startInstant: instant)
561561
if test.isSuite {
562562
context.suiteCount += 1
563563
} else {
@@ -570,8 +570,8 @@ extension Event.Recorder {
570570
case .testEnded:
571571
let test = test!
572572
let id = test.id
573-
let testDataGraph = context.testData.subgraph(at: id.keyPathRepresentation)
574-
let testData = testDataGraph?.value ?? .init()
573+
let testDataGraph = _context.testData.subgraph(at: id.keyPathRepresentation)
574+
let testData = testDataGraph?.value ?? .init(startInstant: instant)
575575
let issues = _issueCounts(in: testDataGraph)
576576
let duration = testData.startInstant.descriptionOfDuration(to: instant)
577577
if issues.issueCount > 0 {
@@ -585,7 +585,7 @@ extension Event.Recorder {
585585

586586
case let .testSkipped(skipInfo):
587587
let test = test!
588-
$context.withLock { context in
588+
$_context.withLock { context in
589589
if test.isSuite {
590590
context.suiteCount += 1
591591
} else {
@@ -613,8 +613,8 @@ extension Event.Recorder {
613613
case let .issueRecorded(issue):
614614
if let test {
615615
let id = test.id.keyPathRepresentation
616-
$context.withLock { context in
617-
var testData = context.testData[id] ?? .init()
616+
$_context.withLock { context in
617+
var testData = context.testData[id] ?? .init(startInstant: instant)
618618
if issue.isKnown {
619619
testData.knownIssueCount += 1
620620
} else {
@@ -673,7 +673,7 @@ extension Event.Recorder {
673673
break
674674

675675
case .runEnded:
676-
let context = $context.wrappedValue
676+
let context = $_context.wrappedValue
677677

678678
let testCount = context.testCount
679679
let issues = _issueCounts(in: context.testData)
@@ -692,15 +692,6 @@ extension Event.Recorder {
692692
return nil
693693
}
694694

695-
/// Record the specified event by generating a representation of it as a
696-
/// human-readable string and writing it using this instance's write function.
697-
///
698-
/// - Parameters:
699-
/// - event: The event to record.
700-
/// - context: The context associated with the event.
701-
///
702-
/// - Returns: Whether any output was written using the recorder's write
703-
/// function.
704695
@discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
705696
if let output = _record(event, in: context) {
706697
write(output)
@@ -723,7 +714,7 @@ extension Event.Recorder {
723714
/// - Returns: The described message, formatted for display using `options`.
724715
///
725716
/// The caller is responsible for presenting this message to the user.
726-
func warning(_ message: String, options: [Event.Recorder.Option]) -> String {
727-
let symbol = Event.Recorder.Symbol.warning.stringValue(options: Set(options))
717+
func warning(_ message: String, options: [Event.ConsoleOutputRecorder.Option]) -> String {
718+
let symbol = Event.ConsoleOutputRecorder.Symbol.warning.stringValue(options: Set(options))
728719
return "\(symbol) \(message)"
729720
}

0 commit comments

Comments
 (0)