@@ -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 ///
@@ -77,6 +299,21 @@ extension Event {
77299
78300 /// A type describing data tracked on a per-test basis.
79301 struct TestData {
302+ /// A lightweight struct containing information about a single issue.
303+ struct IssueInfo : Sendable {
304+ /// The source location where the issue occurred.
305+ var sourceLocation : SourceLocation ?
306+
307+ /// A detailed description of what failed (using expanded description).
308+ var description : String
309+
310+ /// Whether this issue is a known issue.
311+ var isKnown : Bool
312+
313+ /// The severity of this issue.
314+ var severity : Issue . Severity
315+ }
316+
80317 /// The instant at which the test started.
81318 var startInstant : Test . Clock . Instant
82319
@@ -89,6 +326,25 @@ extension Event {
89326
90327 /// Information about the cancellation of this test or test case.
91328 var cancellationInfo : SkipInfo ?
329+
330+ /// Array of all issues recorded for this test (for failure summary).
331+ /// Each issue is stored individually with its own source location.
332+ var issues : [ IssueInfo ] = [ ]
333+
334+ /// The ID of the test, used to construct its fully qualified name in
335+ /// the failure summary.
336+ var testID : Test . ID ?
337+
338+ /// The human-readable name for this test, consistent with
339+ /// ``Test/humanReadableName(withVerbosity:)``.
340+ var humanReadableName : String ?
341+
342+ /// The test case arguments, formatted for display (for parameterized
343+ /// tests).
344+ var testCaseArguments : String ?
345+
346+ /// Whether this is a suite rather than an individual test.
347+ var isSuite : Bool = false
92348 }
93349
94350 /// Data tracked on a per-test basis.
@@ -330,6 +586,46 @@ extension Event.HumanReadableOutputRecorder {
330586 let issueCount = testData. issueCount [ issue. severity] ?? 0
331587 testData. issueCount [ issue. severity] = issueCount + 1
332588 }
589+
590+ // Store individual issue information for failure summary, but only for
591+ // issues whose severity is error or greater.
592+ if issue. severity >= . error {
593+ // Extract detailed failure message
594+ let description : String
595+ if case let . expectationFailed( expectation) = issue. kind {
596+ // Use expandedDebugDescription only when verbose, otherwise use expandedDescription
597+ description = if verbosity > 0 {
598+ expectation. evaluatedExpression. expandedDebugDescription ( )
599+ } else {
600+ expectation. evaluatedExpression. expandedDescription ( )
601+ }
602+ } else if let comment = issue. comments. first {
603+ description = comment. rawValue
604+ } else {
605+ description = " Test failed "
606+ }
607+
608+ let issueInfo = Context . TestData. IssueInfo (
609+ sourceLocation: issue. sourceLocation,
610+ description: description,
611+ isKnown: issue. isKnown,
612+ severity: issue. severity
613+ )
614+ testData. issues. append ( issueInfo)
615+
616+ // Capture test metadata once per test (not per issue).
617+ if testData. testID == nil {
618+ testData. testID = test? . id
619+ }
620+ if testData. humanReadableName == nil {
621+ testData. humanReadableName = test? . humanReadableName ( withVerbosity: verbosity)
622+ }
623+ if testData. testCaseArguments == nil {
624+ testData. testCaseArguments = testCase? . labeledArguments ( )
625+ }
626+ testData. isSuite = test? . isSuite ?? false
627+ }
628+
333629 context. testData [ keyPath] = testData
334630
335631 case . testCaseStarted:
@@ -648,6 +944,22 @@ extension Event.HumanReadableOutputRecorder {
648944
649945 return [ ]
650946 }
947+
948+ /// Generate a failure summary string with all failed tests and their issues.
949+ ///
950+ /// This method creates a ``TestRunSummary`` from the test data graph and
951+ /// formats it for display.
952+ ///
953+ /// - Parameters:
954+ /// - options: Options for formatting (e.g., for ANSI colors and symbols).
955+ ///
956+ /// - Returns: A formatted string containing the failure summary, or `nil`
957+ /// if there were no failures.
958+ func generateFailureSummary( options: Event . ConsoleOutputRecorder . Options ) -> String ? {
959+ let testData = _context. value. withLock { $0. testData }
960+ let summary = Event . TestRunSummary ( from: testData)
961+ return summary. formatted ( with: options)
962+ }
651963}
652964
653965extension Test . ID {
0 commit comments