Skip to content

Commit 8a6d3d5

Browse files
committed
Hierarchical test result display with comprehensive failure analysis
1 parent 8df34ba commit 8a6d3d5

File tree

2 files changed

+185
-34
lines changed

2 files changed

+185
-34
lines changed

Sources/Testing/ABI/EntryPoints/EntryPoint.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func entryPoint(passing args: __CommandLineArguments_v0?, eventHandler: Event.Ha
6262
let eventRecorder = Event.AdvancedConsoleOutputRecorder<ABI.HighestVersion>(options: advancedOptions) { string in
6363
try? FileHandle.stderr.write(string)
6464
}
65-
65+
6666
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
6767
eventRecorder.record(event, in: context)
6868
oldEventHandler(event, context)
@@ -627,15 +627,15 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
627627
#endif
628628

629629
// Warning issues (experimental).
630-
switch args.eventStreamVersionNumber {
631-
case .some(..<ABI.v6_3.versionNumber):
632-
// If the event stream version was explicitly specified to a value < 6.3,
633-
// disable the warning issue event to maintain legacy behavior.
634-
configuration.eventHandlingOptions.isWarningIssueRecordedEventEnabled = false
635-
default:
636-
// Otherwise the requested event stream version is ≥ 6.3, so don't change
637-
// the warning issue event setting.
638-
break
630+
switch args.eventStreamVersionNumber {
631+
case .some(..<ABI.v6_3.versionNumber):
632+
// If the event stream version was explicitly specified to a value < 6.3,
633+
// disable the warning issue event to maintain legacy behavior.
634+
configuration.eventHandlingOptions.isWarningIssueRecordedEventEnabled = false
635+
default:
636+
// Otherwise the requested event stream version is ≥ 6.3, so don't change
637+
// the warning issue event setting.
638+
break
639639
}
640640

641641
return configuration

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

Lines changed: 175 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ extension Event {
7575
/// All issues recorded during the test execution.
7676
/// Includes failures, warnings, and other diagnostic information.
7777
var issues: [ABI.EncodedIssue<V>] = []
78+
79+
/// Detailed messages for each issue, preserving the order and association.
80+
/// Each inner array contains all messages for a single issue.
81+
var issueMessages: [[ABI.EncodedMessage<V>]] = []
7882
}
7983

8084
/// Represents a node in the test hierarchy tree.
@@ -207,8 +211,10 @@ extension Event.AdvancedConsoleOutputRecorder {
207211
}
208212
}
209213

214+
// Generate detailed messages using HumanReadableOutputRecorder
215+
let messages = _humanReadableRecorder.record(event, in: eventContext)
216+
210217
// Convert Event to ABI.EncodedEvent for processing (if needed)
211-
let messages: [Event.HumanReadableOutputRecorder.Message] = []
212218
if let encodedEvent = ABI.EncodedEvent<V>(encoding: event, in: eventContext, messages: messages) {
213219
_processABIEvent(encodedEvent)
214220
}
@@ -417,6 +423,7 @@ extension Event.AdvancedConsoleOutputRecorder {
417423
let issue = encodedEvent.issue {
418424
var testData = context.testData[testID] ?? _TestData()
419425
testData.issues.append(issue)
426+
testData.issueMessages.append(encodedEvent.messages)
420427
context.testData[testID] = testData
421428
}
422429

@@ -538,43 +545,64 @@ extension Event.AdvancedConsoleOutputRecorder {
538545
// Failed Test Details (only if there are failures)
539546
let failedTests = context.testData.filter { $0.value.result == .failed }
540547
if !failedTests.isEmpty {
541-
output += "══════════════════════════════════════ FAILED TEST DETAILS ══════════════════════════════════════\n"
548+
output += "══════════════════════════════════════ FAILED TEST DETAILS (\(failedTests.count)) ══════════════════════════════════════\n"
542549
output += "\n"
543550

544551
// Iterate through all tests that recorded one or more failures
545-
for (testID, testData) in failedTests {
552+
for (testIndex, testEntry) in failedTests.enumerated() {
553+
let (testID, testData) = testEntry
554+
let testNumber = testIndex + 1
555+
let totalFailedTests = failedTests.count
556+
546557
// Get the fully qualified test name by traversing up the hierarchy
547558
let fullyQualifiedName = _getFullyQualifiedTestNameWithFile(testID: testID, context: context)
548559

549560
let failureIcon = _getStatusIcon(for: .failed)
550561
output += "\(failureIcon) \(fullyQualifiedName)\n"
551562

552-
// Show detailed issue information with proper indentation
563+
// Show detailed issue information with enhanced formatting
553564
if !testData.issues.isEmpty {
554-
for issue in testData.issues {
555-
// Get detailed error description
556-
if let error = issue._error {
557-
let errorDescription = "\(error)"
558-
559-
if !errorDescription.isEmpty && errorDescription != "Test failure" {
560-
output += " Expectation failed:\n"
561-
562-
// Split multi-line error descriptions and indent each line
563-
let errorLines = errorDescription.split(separator: "\n", omittingEmptySubsequences: false)
564-
for line in errorLines {
565-
output += " \(line)\n"
566-
}
565+
for (issueIndex, issue) in testData.issues.enumerated() {
566+
// 1. Error Message - Get detailed error description
567+
let issueDescription = _formatDetailedIssueDescription(issue, issueIndex: issueIndex, testData: testData)
568+
569+
if !issueDescription.isEmpty {
570+
let errorLines = issueDescription.split(separator: "\n", omittingEmptySubsequences: false)
571+
for line in errorLines {
572+
output += " \(line)\n"
567573
}
568574
}
569575

570-
// Add source location
576+
// 2. Location and Source Code Context
571577
if let sourceLocation = issue.sourceLocation {
572-
output += " at \(sourceLocation.fileName):\(sourceLocation.line)\n"
578+
output += "\n"
579+
output += " Location: \(sourceLocation.fileName):\(sourceLocation.line):\(sourceLocation.column)\n"
580+
581+
// 3. Source Code Context (2-3 lines before/after)
582+
let codeContext = _getSourceCodeContext(for: sourceLocation)
583+
if !codeContext.isEmpty {
584+
output += "\n"
585+
output += codeContext
586+
}
573587
}
574588

589+
// 4. Statistics - Error counter in lower right
590+
let errorCounter = "[\(testNumber)/\(totalFailedTests)]"
591+
let paddingLength = max(0, 100 - errorCounter.count)
575592
output += "\n"
593+
output += "\(String(repeating: " ", count: paddingLength))\(errorCounter)\n"
594+
595+
// Add spacing between issues (except for the last one)
596+
if issueIndex < testData.issues.count - 1 {
597+
output += "\n"
598+
}
576599
}
577600
}
601+
602+
// Add spacing between tests (except for the last one)
603+
if testIndex < failedTests.count - 1 {
604+
output += "\n"
605+
}
578606
}
579607
}
580608

@@ -680,21 +708,24 @@ extension Event.AdvancedConsoleOutputRecorder {
680708
let paddedTestLine = _padWithDuration(testLine, duration: duration)
681709
output += "\(prefix)\(treePrefix)\(paddedTestLine)\n"
682710

683-
// Render issues for failed tests
711+
// Show concise issue summary for quick overview
684712
if let issues = context.testData[node.testID]?.issues, !issues.isEmpty {
685713
let issuePrefix = prefix + (isLast ? " " : "\(_treeVertical) ")
686714
for (issueIndex, issue) in issues.enumerated() {
687715
let isLastIssue = issueIndex == issues.count - 1
688716
let issueTreePrefix = isLastIssue ? _treeLastBranch : _treeBranch
689717
let issueIcon = _getStatusIcon(for: .failed)
690-
let issueDescription = issue._error?.description ?? "Test failure"
691718

692-
output += "\(issuePrefix)\(issueTreePrefix)\(issueIcon) \(issueDescription)\n"
719+
// Get concise issue description (first line only)
720+
let fullDescription = _formatDetailedIssueDescription(issue, issueIndex: issueIndex, testData: context.testData[node.testID]!)
721+
let conciseDescription = fullDescription.split(separator: "\n").first.map(String.init) ?? "Issue recorded"
722+
723+
output += "\(issuePrefix)\(issueTreePrefix)\(issueIcon) \(conciseDescription)\n"
693724

694-
// Add source location
725+
// Add concise source location
695726
if let sourceLocation = issue.sourceLocation {
696727
let locationPrefix = issuePrefix + (isLastIssue ? " " : "\(_treeVertical) ")
697-
output += "\(locationPrefix)At \(sourceLocation.fileName):\(sourceLocation.line):\(sourceLocation.column)\n"
728+
output += "\(locationPrefix)at \(sourceLocation.fileName):\(sourceLocation.line)\n"
698729
}
699730
}
700731
}
@@ -703,6 +734,126 @@ extension Event.AdvancedConsoleOutputRecorder {
703734
return output
704735
}
705736

737+
/// Format a detailed description of an issue for the Failed Test Details section.
738+
///
739+
/// - Parameters:
740+
/// - issue: The encoded issue to format.
741+
/// - issueIndex: The index of the issue in the testData.issues array.
742+
/// - testData: The test data containing the stored messages.
743+
/// - Returns: A detailed description of what failed.
744+
private func _formatDetailedIssueDescription(_ issue: ABI.EncodedIssue<V>, issueIndex: Int, testData: _TestData) -> String {
745+
// Get the corresponding messages for this issue
746+
guard issueIndex < testData.issueMessages.count else {
747+
// Fallback to error description if available
748+
if let error = issue._error {
749+
return error.description
750+
}
751+
return "Issue recorded"
752+
}
753+
754+
let messages = testData.issueMessages[issueIndex]
755+
756+
// Look for detailed messages (difference, details) that contain the actual failure information
757+
var detailedMessages: [String] = []
758+
759+
for message in messages {
760+
switch message.symbol {
761+
case .difference, .details:
762+
// These contain the detailed expectation failure information
763+
detailedMessages.append(message.text)
764+
case .fail:
765+
// Primary failure message - use if no detailed messages available
766+
if detailedMessages.isEmpty {
767+
detailedMessages.append(message.text)
768+
}
769+
default:
770+
break
771+
}
772+
}
773+
774+
if !detailedMessages.isEmpty {
775+
return detailedMessages.joined(separator: "\n")
776+
}
777+
778+
// Final fallback
779+
if let error = issue._error {
780+
return error.description
781+
}
782+
return "Issue recorded"
783+
}
784+
785+
/// Get source code context around a failing line.
786+
///
787+
/// - Parameters:
788+
/// - sourceLocation: The source location of the failure.
789+
/// - Returns: Formatted source code context with line numbers and failure indicator.
790+
private func _getSourceCodeContext(for sourceLocation: SourceLocation) -> String {
791+
// Try to read the source file
792+
guard let fileContent = _readSourceFile(at: sourceLocation.fileName) else {
793+
return ""
794+
}
795+
796+
let lines = fileContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
797+
let failingLineNumber = sourceLocation.line
798+
let contextLines = 2 // Show 2 lines before and after
799+
800+
// Calculate the range of lines to show
801+
let startLine = max(1, failingLineNumber - contextLines)
802+
let endLine = min(lines.count, failingLineNumber + contextLines)
803+
804+
var output = ""
805+
806+
// Calculate the width needed for line numbers (right-aligned)
807+
let maxLineNumber = endLine
808+
let lineNumberWidth = String(maxLineNumber).count
809+
810+
for lineNumber in startLine...endLine {
811+
let lineIndex = lineNumber - 1 // Convert to 0-based index
812+
guard lineIndex < lines.count else { continue }
813+
814+
let lineContent = lines[lineIndex]
815+
let lineNumberString = String(lineNumber)
816+
let paddingNeeded = lineNumberWidth - lineNumberString.count
817+
let paddedLineNumber = String(repeating: " ", count: paddingNeeded) + lineNumberString
818+
819+
// Add failure indicator for the exact failing line
820+
let indicator = (lineNumber == failingLineNumber) ? ">" : " "
821+
822+
output += " \(paddedLineNumber) \(indicator) | \(lineContent)\n"
823+
}
824+
825+
return output
826+
}
827+
828+
/// Read the contents of a source file.
829+
///
830+
/// - Parameters:
831+
/// - fileName: The name of the file to read.
832+
/// - Returns: The file contents as a string, or nil if the file cannot be read.
833+
private func _readSourceFile(at fileName: String) -> String? {
834+
// Handle relative paths by checking common locations
835+
let possiblePaths = [
836+
fileName, // Try as-is first
837+
"Tests/TestingTests/\(fileName)", // Common test location
838+
"Sources/Testing/\(fileName)", // Main source location
839+
"\(fileName)" // Current directory
840+
]
841+
842+
for path in possiblePaths {
843+
do {
844+
let fileHandle = try FileHandle(forReadingAtPath: path)
845+
let data = try fileHandle.readToEnd()
846+
let content = String(decoding: data, as: UTF8.self)
847+
return content
848+
} catch {
849+
// Continue to next path
850+
continue
851+
}
852+
}
853+
854+
return nil
855+
}
856+
706857
/// Get the status icon for a test result.
707858
///
708859
/// - Parameters:

0 commit comments

Comments
 (0)