@@ -13,6 +13,228 @@ private import Synchronization
1313#endif
1414
1515extension Event {
16+ /// A type that generates a failure summary from test run data.
17+ ///
18+ /// This type encapsulates the logic for collecting failed tests from a test
19+ /// data graph and formatting them into a human-readable failure summary.
20+ private struct TestRunSummary : Sendable {
21+ /// Information about a single failed test case (for parameterized tests).
22+ struct FailedTestCase : Sendable {
23+ /// The test case arguments for this parameterized test case.
24+ var arguments : String
25+
26+ /// All issues recorded for this test case.
27+ var issues : [ HumanReadableOutputRecorder . Context . TestData . IssueInfo ]
28+ }
29+
30+ /// Information about a single failed test.
31+ struct FailedTest : Sendable {
32+ /// The name components from ``Test/ID`` (excludes the module name).
33+ var nameComponents : [ String ]
34+
35+ /// The human-readable name for this test, consistent with
36+ /// ``Test/humanReadableName(withVerbosity:)``.
37+ var humanReadableName : String
38+
39+ /// Whether this is a suite rather than an individual test.
40+ var isSuite : Bool
41+
42+ /// For non-parameterized tests: issues recorded directly on the test.
43+ var issues : [ HumanReadableOutputRecorder . Context . TestData . IssueInfo ]
44+
45+ /// For parameterized tests: test cases with their issues.
46+ var testCases : [ FailedTestCase ]
47+ }
48+
49+ /// The list of failed tests collected from the test run.
50+ private let failedTests : [ FailedTest ]
51+
52+ /// Initialize a test run summary by collecting failures from a test data
53+ /// graph.
54+ ///
55+ /// - Parameters:
56+ /// - testData: The root test data graph to traverse.
57+ fileprivate init ( from testData: Graph < HumanReadableOutputRecorder . Context . TestDataKey , HumanReadableOutputRecorder . Context . TestData ? > ) {
58+ var testMap : [ String : FailedTest ] = [ : ]
59+
60+ testData. forEach { keyPath, value in
61+ guard let testData = value, !testData. issues. isEmpty else { return }
62+ guard let testID = testData. testID else { return }
63+
64+ // Determine if this node represents a parameterized test case by
65+ // checking if the key path contains a parameterized test case ID.
66+ // Non-parameterized test cases have nil argument IDs and should be
67+ // treated as regular tests.
68+ let isTestCase = keyPath. contains {
69+ if case let . testCaseID( id) = $0 {
70+ return id. argumentIDs != nil
71+ }
72+ return false
73+ }
74+
75+ let nameComponents = testID. nameComponents
76+ let pathKey = nameComponents. joined ( separator: " / " )
77+ let humanReadableName = testData. humanReadableName ?? nameComponents. last ?? " Unknown "
78+
79+ if isTestCase {
80+ // This represents a parameterized test case — group it under
81+ // its parent test.
82+ if var parentTest = testMap [ pathKey] {
83+ if let arguments = testData. testCaseArguments, !arguments. isEmpty {
84+ parentTest. testCases. append ( FailedTestCase (
85+ arguments: arguments,
86+ issues: testData. issues
87+ ) )
88+ testMap [ pathKey] = parentTest
89+ }
90+ } else {
91+ let parentTest = FailedTest (
92+ nameComponents: nameComponents,
93+ humanReadableName: humanReadableName,
94+ isSuite: testData. isSuite,
95+ issues: [ ] ,
96+ testCases: ( testData. testCaseArguments? . isEmpty ?? true ) ? [ ] : [ FailedTestCase (
97+ arguments: testData. testCaseArguments ?? " " ,
98+ issues: testData. issues
99+ ) ]
100+ )
101+ testMap [ pathKey] = parentTest
102+ }
103+ } else {
104+ // This represents a test function, not a parameterized test case.
105+ let failedTest = FailedTest (
106+ nameComponents: nameComponents,
107+ humanReadableName: humanReadableName,
108+ isSuite: testData. isSuite,
109+ issues: testData. issues,
110+ testCases: [ ]
111+ )
112+ testMap [ pathKey] = failedTest
113+ }
114+ }
115+
116+ self . failedTests = Array ( testMap. values) . filter { !$0. issues. isEmpty || !$0. testCases. isEmpty }
117+ }
118+
119+ /// Generate a formatted failure summary string.
120+ ///
121+ /// - Parameters:
122+ /// - options: Options for formatting (e.g., for ANSI colors and symbols).
123+ ///
124+ /// - Returns: A formatted string containing the failure summary, or `nil`
125+ /// if there were no failures.
126+ public func formatted( with options: Event . ConsoleOutputRecorder . Options ) -> String ? {
127+ // If no failures, return nil
128+ guard !failedTests. isEmpty else {
129+ return nil
130+ }
131+
132+ // Begin with the summary header.
133+ var summary = header ( )
134+
135+ // Get the failure symbol
136+ let failSymbol = Event . Symbol. fail. stringValue ( options: options)
137+
138+ // Format each failed test
139+ for failedTest in failedTests {
140+ summary += formatFailedTest ( failedTest, withSymbol: failSymbol)
141+ }
142+
143+ return summary
144+ }
145+
146+ /// Generate the summary header with failure counts.
147+ ///
148+ /// - Returns: A string containing the header line.
149+ private func header( ) -> String {
150+ let failedTestsPhrase = failedTests. count. counting ( " test " )
151+ var totalIssuesCount = 0
152+ for test in failedTests {
153+ totalIssuesCount += test. issues. count
154+ for testCase in test. testCases {
155+ totalIssuesCount += testCase. issues. count
156+ }
157+ }
158+ let issuesPhrase = totalIssuesCount. counting ( " issue " )
159+ return " Test run had \( failedTestsPhrase) which recorded \( issuesPhrase) total: \n "
160+ }
161+
162+ /// Format a single failed test entry.
163+ ///
164+ /// - Parameters:
165+ /// - failedTest: The failed test to format.
166+ /// - symbol: The failure symbol string to use.
167+ ///
168+ /// - Returns: A formatted string representing the failed test and its
169+ /// issues.
170+ private func formatFailedTest( _ failedTest: FailedTest , withSymbol symbol: String ) -> String {
171+ var result = " "
172+
173+ // Build fully qualified name
174+ let fullyQualifiedName = fullyQualifiedName ( for: failedTest)
175+
176+ // Use "Suite" or "Test" based on whether this is a suite
177+ let label = failedTest. isSuite ? " Suite " : " Test "
178+ result += " \( symbol) \( label) \( fullyQualifiedName) \n "
179+
180+ // For parameterized tests: show test cases grouped under the parent test
181+ if !failedTest. testCases. isEmpty {
182+ for testCase in failedTest. testCases {
183+ // Show test case with argument count phrase and arguments
184+ let argumentCount = testCase. arguments. split ( separator: " , " ) . count
185+ let argumentPhrase = argumentCount. counting ( " argument " )
186+ result += " Test case with \( argumentPhrase) : ( \( testCase. arguments) ) \n "
187+ // List each issue for this test case with additional indentation
188+ for issue in testCase. issues {
189+ result += formatIssue ( issue, indentLevel: 2 )
190+ }
191+ }
192+ } else {
193+ // For non-parameterized tests: show issues directly
194+ for issue in failedTest. issues {
195+ result += formatIssue ( issue)
196+ }
197+ }
198+
199+ return result
200+ }
201+
202+ /// Build the fully qualified name for a failed test.
203+ ///
204+ /// - Parameters:
205+ /// - failedTest: The failed test.
206+ ///
207+ /// - Returns: The fully qualified name, using the test's
208+ /// ``Test/humanReadableName(withVerbosity:)`` for the final component.
209+ private func fullyQualifiedName( for failedTest: FailedTest ) -> String {
210+ // Use the name components from Test.ID (which already exclude the module
211+ // name) and replace the last component with the human-readable name to
212+ // be consistent with how tests are described elsewhere in the output.
213+ var components = failedTest. nameComponents
214+ if !components. isEmpty {
215+ components [ components. count - 1 ] = failedTest. humanReadableName
216+ }
217+ return components. joined ( separator: " / " )
218+ }
219+
220+ /// Format a single issue entry.
221+ ///
222+ /// - Parameters:
223+ /// - issue: The issue to format.
224+ /// - indentLevel: The number of indentation levels (each level is 2 spaces).
225+ /// Defaults to 1.
226+ ///
227+ /// - Returns: A formatted string representing the issue with indentation.
228+ private func formatIssue( _ issue: HumanReadableOutputRecorder . Context . TestData . IssueInfo , indentLevel: Int = 1 ) -> String {
229+ let indent = String ( repeating: " " , count: indentLevel)
230+ var result = " \( indent) - \( issue. description) \n "
231+ if let location = issue. sourceLocation {
232+ result += " \( indent) at \( location) \n "
233+ }
234+ return result
235+ }
236+ }
237+
16238 /// A type which handles ``Event`` instances and outputs representations of
17239 /// them as human-readable messages.
18240 ///
@@ -68,6 +290,21 @@ extension Event {
68290
69291 /// A type describing data tracked on a per-test basis.
70292 struct TestData {
293+ /// A lightweight struct containing information about a single issue.
294+ struct IssueInfo : Sendable {
295+ /// The source location where the issue occurred.
296+ var sourceLocation : SourceLocation ?
297+
298+ /// A detailed description of what failed (using expanded description).
299+ var description : String
300+
301+ /// Whether this issue is a known issue.
302+ var isKnown : Bool
303+
304+ /// The severity of this issue.
305+ var severity : Issue . Severity
306+ }
307+
71308 /// The instant at which the test started.
72309 var startInstant : Test . Clock . Instant
73310
@@ -80,6 +317,25 @@ extension Event {
80317
81318 /// Information about the cancellation of this test or test case.
82319 var cancellationInfo : SkipInfo ?
320+
321+ /// Array of all issues recorded for this test (for failure summary).
322+ /// Each issue is stored individually with its own source location.
323+ var issues : [ IssueInfo ] = [ ]
324+
325+ /// The ID of the test, used to construct its fully qualified name in
326+ /// the failure summary.
327+ var testID : Test . ID ?
328+
329+ /// The human-readable name for this test, consistent with
330+ /// ``Test/humanReadableName(withVerbosity:)``.
331+ var humanReadableName : String ?
332+
333+ /// The test case arguments, formatted for display (for parameterized
334+ /// tests).
335+ var testCaseArguments : String ?
336+
337+ /// Whether this is a suite rather than an individual test.
338+ var isSuite : Bool = false
83339 }
84340
85341 /// Data tracked on a per-test basis.
@@ -321,6 +577,46 @@ extension Event.HumanReadableOutputRecorder {
321577 let issueCount = testData. issueCount [ issue. severity] ?? 0
322578 testData. issueCount [ issue. severity] = issueCount + 1
323579 }
580+
581+ // Store individual issue information for failure summary, but only for
582+ // issues whose severity is error or greater.
583+ if issue. severity >= . error {
584+ // Extract detailed failure message
585+ let description : String
586+ if case let . expectationFailed( expectation) = issue. kind {
587+ // Use expandedDebugDescription only when verbose, otherwise use expandedDescription
588+ description = if verbosity > 0 {
589+ expectation. evaluatedExpression. expandedDebugDescription ( )
590+ } else {
591+ expectation. evaluatedExpression. expandedDescription ( )
592+ }
593+ } else if let comment = issue. comments. first {
594+ description = comment. rawValue
595+ } else {
596+ description = " Test failed "
597+ }
598+
599+ let issueInfo = Context . TestData. IssueInfo (
600+ sourceLocation: issue. sourceLocation,
601+ description: description,
602+ isKnown: issue. isKnown,
603+ severity: issue. severity
604+ )
605+ testData. issues. append ( issueInfo)
606+
607+ // Capture test metadata once per test (not per issue).
608+ if testData. testID == nil {
609+ testData. testID = test? . id
610+ }
611+ if testData. humanReadableName == nil {
612+ testData. humanReadableName = test? . humanReadableName ( withVerbosity: verbosity)
613+ }
614+ if testData. testCaseArguments == nil {
615+ testData. testCaseArguments = testCase? . labeledArguments ( )
616+ }
617+ testData. isSuite = test? . isSuite ?? false
618+ }
619+
324620 context. testData [ keyPath] = testData
325621
326622 case . testCaseStarted:
@@ -636,6 +932,22 @@ extension Event.HumanReadableOutputRecorder {
636932
637933 return [ ]
638934 }
935+
936+ /// Generate a failure summary string with all failed tests and their issues.
937+ ///
938+ /// This method creates a ``TestRunSummary`` from the test data graph and
939+ /// formats it for display.
940+ ///
941+ /// - Parameters:
942+ /// - options: Options for formatting (e.g., for ANSI colors and symbols).
943+ ///
944+ /// - Returns: A formatted string containing the failure summary, or `nil`
945+ /// if there were no failures.
946+ func generateFailureSummary( options: Event . ConsoleOutputRecorder . Options ) -> String ? {
947+ let testData = _context. value. withLock { $0. testData }
948+ let summary = Event . TestRunSummary ( from: testData)
949+ return summary. formatted ( with: options)
950+ }
639951}
640952
641953extension Test . ID {
0 commit comments