@@ -64,6 +64,18 @@ extension Event {
6464
6565 /// A type describing data tracked on a per-test basis.
6666 struct TestData {
67+ /// A lightweight struct containing information about a single issue.
68+ struct IssueInfo : Sendable {
69+ /// The source location where the issue occurred.
70+ var sourceLocation : SourceLocation ?
71+
72+ /// A detailed description of what failed (using expanded debug description).
73+ var description : String
74+
75+ /// Whether this issue is a known issue.
76+ var isKnown : Bool
77+ }
78+
6779 /// The instant at which the test started.
6880 var startInstant : Test . Clock . Instant
6981
@@ -76,6 +88,16 @@ extension Event {
7688
7789 /// Information about the cancellation of this test or test case.
7890 var cancellationInfo : SkipInfo ?
91+
92+ /// Array of all issues recorded for this test (for failure summary).
93+ /// Each issue is stored individually with its own source location.
94+ var issues : [ IssueInfo ] = [ ]
95+
96+ /// The test's display name, if any.
97+ var testDisplayName : String ?
98+
99+ /// The test case arguments, formatted for display (for parameterized tests).
100+ var testCaseArguments : String ?
79101 }
80102
81103 /// Data tracked on a per-test basis.
@@ -317,6 +339,36 @@ extension Event.HumanReadableOutputRecorder {
317339 let issueCount = testData. issueCount [ issue. severity] ?? 0
318340 testData. issueCount [ issue. severity] = issueCount + 1
319341 }
342+
343+ // Store individual issue information for failure summary (only for errors)
344+ if issue. severity == . error {
345+ // Extract detailed failure message using expandedDebugDescription
346+ let description : String
347+ if case let . expectationFailed( expectation) = issue. kind {
348+ // Use expandedDebugDescription for full detail (variable values expanded)
349+ description = expectation. evaluatedExpression. expandedDebugDescription ( )
350+ } else if let comment = issue. comments. first {
351+ description = comment. rawValue
352+ } else {
353+ description = " Test failed "
354+ }
355+
356+ let issueInfo = Context . TestData. IssueInfo (
357+ sourceLocation: issue. sourceLocation,
358+ description: description,
359+ isKnown: issue. isKnown
360+ )
361+ testData. issues. append ( issueInfo)
362+
363+ // Capture test display name and test case arguments once per test (not per issue)
364+ if testData. testDisplayName == nil {
365+ testData. testDisplayName = test? . displayName
366+ }
367+ if testData. testCaseArguments == nil {
368+ testData. testCaseArguments = testCase? . labeledArguments ( )
369+ }
370+ }
371+
320372 context. testData [ keyPath] = testData
321373
322374 case . testCaseStarted:
@@ -629,6 +681,124 @@ extension Event.HumanReadableOutputRecorder {
629681
630682 return [ ]
631683 }
684+
685+ /// Generate a failure summary string with all failed tests and their issues.
686+ ///
687+ /// This method traverses the test graph and formats a summary of all failures
688+ /// that occurred during the test run. It includes the fully qualified test name
689+ /// (with suite path), individual issues with their source locations, and uses
690+ /// indentation to clearly delineate issue boundaries.
691+ ///
692+ /// - Parameters:
693+ /// - options: Options for formatting (e.g., for ANSI colors and symbols).
694+ ///
695+ /// - Returns: A formatted string containing the failure summary, or an empty
696+ /// string if there were no failures.
697+ public func generateFailureSummary( options: Event . ConsoleOutputRecorder . Options ) -> String {
698+ let context = _context. rawValue
699+
700+ // Collect all failed tests (tests with error issues)
701+ struct FailedTestInfo {
702+ var testPath : [ String ] // Full path including suite names
703+ var testName : String
704+ var issues : [ Context . TestData . IssueInfo ]
705+ var testDisplayName : String ?
706+ var testCaseArguments : String ?
707+ }
708+
709+ var failedTests : [ FailedTestInfo ] = [ ]
710+
711+ // Traverse the graph to find all tests with failures
712+ func traverse( graph: Graph < Context . TestDataKey , Context . TestData ? > , path: [ String ] ) {
713+ // Check if this node has test data with failures
714+ if let testData = graph. value, !testData. issues. isEmpty {
715+ let testName = path. last ?? " Unknown "
716+
717+ failedTests. append ( FailedTestInfo (
718+ testPath: path,
719+ testName: testName,
720+ issues: testData. issues,
721+ testDisplayName: testData. testDisplayName,
722+ testCaseArguments: testData. testCaseArguments
723+ ) )
724+ }
725+
726+ // Recursively traverse children
727+ for (key, childGraph) in graph. children {
728+ let pathComponent : String ?
729+ switch key {
730+ case let . string( s) :
731+ let parts = s. split ( separator: " : " )
732+ if s. hasSuffix ( " .swift: " ) || ( parts. count >= 2 && parts [ 0 ] . hasSuffix ( " .swift " ) ) {
733+ pathComponent = nil
734+ } else {
735+ pathComponent = s
736+ }
737+ case let . testCaseID( id) :
738+ // Only include parameterized test case IDs in path, skip non-parameterized ones
739+ if let argumentIDs = id. argumentIDs, let discriminator = id. discriminator {
740+ pathComponent = " arguments: \( argumentIDs) , discriminator: \( discriminator) "
741+ } else {
742+ // Non-parameterized test - don't add to path
743+ pathComponent = nil
744+ }
745+ }
746+
747+ let newPath = pathComponent. map { path + [ $0] } ?? path
748+ traverse ( graph: childGraph, path: newPath)
749+ }
750+ }
751+
752+ // Start traversal from root
753+ traverse ( graph: context. testData, path: [ ] )
754+
755+ // If no failures, return empty string
756+ guard !failedTests. isEmpty else {
757+ return " "
758+ }
759+
760+ var summary = " "
761+
762+ // Header with failure count
763+ let testWord = failedTests. count == 1 ? " test " : " tests "
764+ let totalIssues = failedTests. reduce ( 0 ) { $0 + $1. issues. count }
765+ let issueWord = totalIssues == 1 ? " issue " : " issues "
766+ summary += " Test run had \( failedTests. count) failed \( testWord) with \( totalIssues) \( issueWord) : \n "
767+
768+ // Get the failure symbol
769+ let failSymbol = Event . Symbol. fail. stringValue ( options: options)
770+
771+ // List each failed test
772+ for failedTest in failedTests {
773+ // Build fully qualified name with suite path
774+ var fullyQualifiedName = failedTest. testPath. joined ( separator: " / " )
775+
776+ // Use display name for the last component if available
777+ if let displayName = failedTest. testDisplayName,
778+ !failedTest. testPath. isEmpty {
779+ // Replace the last component (test name) with display name
780+ let pathWithoutLast = failedTest. testPath. dropLast ( )
781+ fullyQualifiedName = ( pathWithoutLast + [ #"" \#( displayName) ""# ] ) . joined ( separator: " / " )
782+ }
783+
784+ summary += " \( failSymbol) \( fullyQualifiedName) \n "
785+
786+ // Show test case arguments for parameterized tests (once per test, not per issue)
787+ if let arguments = failedTest. testCaseArguments, !arguments. isEmpty {
788+ summary += " ( \( arguments) ) \n "
789+ }
790+
791+ // List each issue for this test with indentation
792+ for issue in failedTest. issues {
793+ summary += " - \( issue. description) \n "
794+ if let location = issue. sourceLocation {
795+ summary += " at \( location. fileID) : \( location. line) \n "
796+ }
797+ }
798+ }
799+
800+ return summary
801+ }
632802}
633803
634804extension Test . ID {
0 commit comments