Skip to content

Commit 35c97f7

Browse files
authored
Split up ConsoleOutputRecorder into two parts. (#145)
* Split up `ConsoleOutputRecorder` into two parts. This PR splits `Event.ConsoleOutputRecorder` such that it only contains the code necessary to produce _console output_ (that is, output destined for a terminal with ANSI escape codes, etc.) The logic to produce human-readable text is moved to a separate type, `Event.HumanReadableOutputRecorder`. This type has no knowledge of where the text it generates will be rendered or consumed. By splitting this type into two pieces, we (or external developers) can readily create new event-recording mechanisms that use the same text we do, but which translate that text to different representations such as HTML or `AttributedString`.
1 parent 91fb1b7 commit 35c97f7

File tree

8 files changed

+631
-525
lines changed

8 files changed

+631
-525
lines changed

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

Lines changed: 73 additions & 489 deletions
Large diffs are not rendered by default.
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
extension Event {
12+
/// A type which handles ``Event`` instances and outputs representations of
13+
/// them as human-readable messages.
14+
///
15+
/// This type can be used compositionally to produce output in other
16+
/// human-readable formats such as rich text or HTML.
17+
///
18+
/// The format of the output is not meant to be machine-readable and is
19+
/// subject to change. For machine-readable output, use ``JUnitXMLRecorder``.
20+
@_spi(ExperimentalEventRecording)
21+
public struct HumanReadableOutputRecorder: Sendable {
22+
/// A type describing a human-readable message produced by an instance of
23+
/// ``Event/HumanReadableOutputRecorder``.
24+
public struct Message: Sendable {
25+
/// The symbol associated with this message, if any.
26+
var symbol: Symbol?
27+
28+
/// The human-readable message.
29+
var stringValue: String
30+
}
31+
32+
/// A type that contains mutable context for
33+
/// ``Event/ConsoleOutputRecorder``.
34+
private struct _Context {
35+
/// The instant at which the run started.
36+
var runStartInstant: Test.Clock.Instant?
37+
38+
/// The number of tests started or skipped during the run.
39+
///
40+
/// This value does not include test suites.
41+
var testCount = 0
42+
43+
/// The number of test suites started or skipped during the run.
44+
var suiteCount = 0
45+
46+
/// A type describing data tracked on a per-test basis.
47+
struct TestData {
48+
/// The instant at which the test started.
49+
var startInstant: Test.Clock.Instant
50+
51+
/// The number of issues recorded for the test.
52+
var issueCount = 0
53+
54+
/// The number of known issues recorded for the test.
55+
var knownIssueCount = 0
56+
}
57+
58+
/// Data tracked on a per-test basis.
59+
var testData = Graph<String, TestData?>()
60+
}
61+
62+
/// This event recorder's mutable context about events it has received,
63+
/// which may be used to inform how subsequent events are written.
64+
@Locked private var _context = _Context()
65+
66+
/// Initialize a new human-readable event recorder.
67+
///
68+
/// Output from the testing library is converted to "messages" using the
69+
/// ``Event/HumanReadableOutputRecorder/record(_:)`` function. The format of
70+
/// those messages is, as the type's name suggests, not meant to be
71+
/// machine-readable and is subject to change.
72+
public init() {}
73+
}
74+
}
75+
76+
// MARK: -
77+
78+
extension Event.HumanReadableOutputRecorder {
79+
/// Get a string representing an array of comments, formatted for output.
80+
///
81+
/// - Parameters:
82+
/// - comments: The comments that should be formatted.
83+
///
84+
/// - Returns: A formatted string representing `comments`, or `nil` if there
85+
/// are none.
86+
private func _formattedComments(_ comments: [Comment]) -> [Message] {
87+
// Insert an arrow character at the start of each comment, then indent any
88+
// additional lines in the comment to align them with the arrow.
89+
comments.lazy
90+
.flatMap { comment in
91+
let lines = comment.rawValue.split(whereSeparator: \.isNewline)
92+
if let firstLine = lines.first {
93+
let remainingLines = lines.dropFirst()
94+
return CollectionOfOne(Message(symbol: .details, stringValue: String(firstLine))) + remainingLines.lazy
95+
.map(String.init)
96+
.map { Message(stringValue: $0) }
97+
}
98+
return []
99+
}
100+
}
101+
102+
/// Get a string representing the comments attached to a test, formatted for
103+
/// output.
104+
///
105+
/// - Parameters:
106+
/// - test: The test whose comments should be formatted.
107+
///
108+
/// - Returns: A formatted string representing the comments attached to `test`,
109+
/// or `nil` if there are none.
110+
private func _formattedComments(for test: Test) -> [Message] {
111+
_formattedComments(test.comments(from: Comment.self))
112+
}
113+
114+
/// Get the total number of issues recorded in a graph of test data
115+
/// structures.
116+
///
117+
/// - Parameters:
118+
/// - graph: The graph to walk while counting issues.
119+
///
120+
/// - Returns: A tuple containing the number of issues recorded in `graph`.
121+
private func _issueCounts(in graph: Graph<String, Event.HumanReadableOutputRecorder._Context.TestData?>?) -> (issueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) {
122+
guard let graph else {
123+
return (0, 0, 0, "")
124+
}
125+
let issueCount = graph.compactMap(\.value?.issueCount).reduce(into: 0, +=)
126+
let knownIssueCount = graph.compactMap(\.value?.knownIssueCount).reduce(into: 0, +=)
127+
let totalIssueCount = issueCount + knownIssueCount
128+
129+
// Construct a string describing the issue counts.
130+
let description = switch (issueCount > 0, knownIssueCount > 0) {
131+
case (true, true):
132+
" with \(totalIssueCount.counting("issue")) (including \(knownIssueCount.counting("known issue")))"
133+
case (false, true):
134+
" with \(knownIssueCount.counting("known issue"))"
135+
case (true, false):
136+
" with \(totalIssueCount.counting("issue"))"
137+
case(false, false):
138+
""
139+
}
140+
141+
return (issueCount, knownIssueCount, totalIssueCount, description)
142+
}
143+
}
144+
145+
extension Test.Case {
146+
/// The arguments of this test case, formatted for presentation, prefixed by
147+
/// their corresponding parameter label when available.
148+
fileprivate var labeledArguments: String {
149+
arguments.lazy
150+
.map { argument in
151+
let valueDescription = String(describingForTest: argument.value)
152+
153+
let label = argument.parameter.secondName ?? argument.parameter.firstName
154+
guard label != "_" else {
155+
return valueDescription
156+
}
157+
return "\(label)\(valueDescription)"
158+
}
159+
.joined(separator: ", ")
160+
}
161+
}
162+
163+
// MARK: -
164+
165+
extension Event.HumanReadableOutputRecorder {
166+
/// Record the specified event by generating zero or more messages that
167+
/// describe it.
168+
///
169+
/// - Parameters:
170+
/// - event: The event to record.
171+
/// - eventContext: The context associated with the event.
172+
///
173+
/// - Returns: An array of zero or more messages that can be displayed to the
174+
/// user.
175+
@discardableResult public func record(_ event: borrowing Event, in eventContext: borrowing Event.Context) -> [Message] {
176+
let test = eventContext.test
177+
var testName: String
178+
if let displayName = test?.displayName {
179+
testName = "\"\(displayName)\""
180+
} else if let test {
181+
testName = test.name
182+
} else {
183+
testName = "«unknown»"
184+
}
185+
let instant = event.instant
186+
187+
switch event.kind {
188+
case .runStarted:
189+
$_context.withLock { context in
190+
context.runStartInstant = instant
191+
}
192+
var comments: [Comment] = [
193+
"Swift Version: \(swiftStandardLibraryVersion)",
194+
"Testing Library Version: \(testingLibraryVersion)",
195+
]
196+
#if targetEnvironment(simulator)
197+
comments.append("OS Version (Simulator): \(simulatorVersion)")
198+
comments.append("OS Version (Host): \(operatingSystemVersion)")
199+
#else
200+
comments.append("OS Version: \(operatingSystemVersion)")
201+
#endif
202+
return CollectionOfOne(
203+
Message(
204+
symbol: .default,
205+
stringValue: "Test run started."
206+
)
207+
) + _formattedComments(comments)
208+
209+
case .planStepStarted, .planStepEnded:
210+
// Suppress events of these kinds from output as they are not generally
211+
// interesting in human-readable output.
212+
break
213+
214+
case .testStarted:
215+
let test = test!
216+
$_context.withLock { context in
217+
context.testData[test.id.keyPathRepresentation] = .init(startInstant: instant)
218+
if test.isSuite {
219+
context.suiteCount += 1
220+
} else {
221+
context.testCount += 1
222+
}
223+
}
224+
return [
225+
Message(
226+
symbol: .default,
227+
stringValue: "Test \(testName) started."
228+
)
229+
]
230+
231+
case .testEnded:
232+
let test = test!
233+
let id = test.id
234+
let testDataGraph = _context.testData.subgraph(at: id.keyPathRepresentation)
235+
let testData = testDataGraph?.value ?? .init(startInstant: instant)
236+
let issues = _issueCounts(in: testDataGraph)
237+
let duration = testData.startInstant.descriptionOfDuration(to: instant)
238+
return if issues.issueCount > 0 {
239+
CollectionOfOne(
240+
Message(
241+
symbol: .fail,
242+
stringValue: "Test \(testName) failed after \(duration)\(issues.description)."
243+
)
244+
) + _formattedComments(for: test)
245+
} else {
246+
[
247+
Message(
248+
symbol: .pass(knownIssueCount: issues.knownIssueCount),
249+
stringValue: "Test \(testName) passed after \(duration)\(issues.description)."
250+
)
251+
]
252+
}
253+
254+
case let .testSkipped(skipInfo):
255+
let test = test!
256+
$_context.withLock { context in
257+
if test.isSuite {
258+
context.suiteCount += 1
259+
} else {
260+
context.testCount += 1
261+
}
262+
}
263+
return if let comment = skipInfo.comment {
264+
[
265+
Message(symbol: .skip, stringValue: "Test \(testName) skipped: \"\(comment.rawValue)\"")
266+
]
267+
} else {
268+
[
269+
Message(symbol: .skip, stringValue: "Test \(testName) skipped.")
270+
]
271+
}
272+
273+
case .expectationChecked:
274+
// Suppress events of this kind from output as they are not generally
275+
// interesting in human-readable output.
276+
break
277+
278+
case let .issueRecorded(issue):
279+
if let test {
280+
let id = test.id.keyPathRepresentation
281+
$_context.withLock { context in
282+
var testData = context.testData[id] ?? .init(startInstant: instant)
283+
if issue.isKnown {
284+
testData.knownIssueCount += 1
285+
} else {
286+
testData.issueCount += 1
287+
}
288+
context.testData[id] = testData
289+
}
290+
}
291+
let parameterCount = if let parameters = test?.parameters {
292+
parameters.count
293+
} else {
294+
0
295+
}
296+
let labeledArguments = if let testCase = eventContext.testCase {
297+
testCase.labeledArguments
298+
} else {
299+
""
300+
}
301+
let symbol: Event.Symbol
302+
let known: String
303+
if issue.isKnown {
304+
symbol = .pass(knownIssueCount: 1)
305+
known = " known"
306+
} else {
307+
symbol = .fail
308+
known = "n"
309+
}
310+
311+
var additionalMessages = [Message]()
312+
if case let .expectationFailed(expectation) = issue.kind, let differenceDescription = expectation.differenceDescription {
313+
additionalMessages.append(Message(symbol: .difference, stringValue: differenceDescription))
314+
}
315+
additionalMessages += _formattedComments(issue.comments)
316+
317+
let atSourceLocation = issue.sourceLocation.map { " at \($0)" } ?? ""
318+
let primaryMessage: Message = if parameterCount == 0 {
319+
Message(
320+
symbol: symbol,
321+
stringValue: "Test \(testName) recorded a\(known) issue\(atSourceLocation): \(issue.kind)"
322+
)
323+
} else {
324+
Message(
325+
symbol: symbol,
326+
stringValue: "Test \(testName) recorded a\(known) issue with \(parameterCount.counting("argument")) \(labeledArguments)\(atSourceLocation): \(issue.kind)"
327+
)
328+
}
329+
return CollectionOfOne(primaryMessage) + additionalMessages
330+
331+
case .testCaseStarted:
332+
guard let testCase = eventContext.testCase, testCase.isParameterized else {
333+
break
334+
}
335+
336+
return [
337+
Message(
338+
symbol: .default,
339+
stringValue: "Passing \(testCase.arguments.count.counting("argument")) \(testCase.labeledArguments) to \(testName)"
340+
)
341+
]
342+
343+
case .testCaseEnded:
344+
break
345+
346+
case .runEnded:
347+
let context = $_context.wrappedValue
348+
349+
let testCount = context.testCount
350+
let issues = _issueCounts(in: context.testData)
351+
let runStartInstant = context.runStartInstant ?? instant
352+
let duration = runStartInstant.descriptionOfDuration(to: instant)
353+
354+
return if issues.issueCount > 0 {
355+
[
356+
Message(
357+
symbol: .fail,
358+
stringValue: "Test run with \(testCount.counting("test")) failed after \(duration)\(issues.description)."
359+
)
360+
]
361+
} else {
362+
[
363+
Message(
364+
symbol: .pass(knownIssueCount: issues.knownIssueCount),
365+
stringValue: "Test run with \(testCount.counting("test")) passed after \(duration)\(issues.description)."
366+
)
367+
]
368+
}
369+
}
370+
371+
return []
372+
}
373+
}
374+
375+
// MARK: - Codable
376+
377+
extension Event.HumanReadableOutputRecorder.Message: Codable {}

Sources/Testing/Events/Recorder/Event.JUnitXMLRecorder.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
extension Event {
1212
/// A type which handles ``Event`` instances and outputs representations of
1313
/// them as JUnit-compatible XML.
14+
@_spi(ExperimentalEventRecording)
1415
public struct JUnitXMLRecorder: Sendable {
1516
/// The write function for this event recorder.
1617
var write: @Sendable (String) -> Void
@@ -65,7 +66,7 @@ extension Event {
6566
}
6667
}
6768

68-
extension Event.JUnitXMLRecorder: EventRecorder {
69+
extension Event.JUnitXMLRecorder {
6970
/// Record the specified event by generating a representation of it as a
7071
/// human-readable string.
7172
///

0 commit comments

Comments
 (0)