@@ -35,6 +35,10 @@ class OutputParser {
3535 private var passedTestDurations : [ String : Double ] = [ : ]
3636 private var failedTestDurations : [ String : Double ] = [ : ]
3737
38+ // Crash / signal tracking
39+ private var lastStartedTestName : String ?
40+ private var pendingSignalCode : Int ?
41+
3842 // Build info tracking - phases grouped by target
3943 private var targetPhases : [ String : [ String ] ] = [ : ] // target -> [phase names]
4044 private var targetDurations : [ String : String ] = [ : ] // target -> duration
@@ -451,6 +455,20 @@ class OutputParser {
451455 return nil
452456 } ( )
453457
458+ // Safety net: if a test was started but never completed and testRunFailed is set
459+ if testRunFailed, let testName = lastStartedTestName {
460+ let normalizedName = normalizeTestName ( testName)
461+ if !hasSeenSimilarTest( normalizedName) {
462+ let message =
463+ pendingSignalCode. map { " Crashed (signal \( $0) ): last test started before crash " }
464+ ?? " Test did not complete (possible crash or timeout) "
465+ failedTests. append ( FailedTest ( test: testName, message: message, file: nil , line: nil ) )
466+ seenTestNames. insert ( normalizedName)
467+ }
468+ lastStartedTestName = nil
469+ pendingSignalCode = nil
470+ }
471+
454472 // Determine build status with priority on parsed results over testRunFailed flag
455473 // Issue #52: -skipMacroValidation can set testRunFailed even when tests pass
456474 let status : String = {
@@ -647,6 +665,8 @@ class OutputParser {
647665 shouldParseBuildInfo = false
648666 targetDependencies = [ : ]
649667 currentDependencyTarget = nil
668+ lastStartedTestName = nil
669+ pendingSignalCode = nil
650670 }
651671
652672 private func parseLine( _ line: String ) {
@@ -700,11 +720,49 @@ class OutputParser {
700720 || line. hasPrefix ( " RegisterWithLaunchServices " )
701721 || line. hasPrefix ( " Validate " ) || line. contains ( " Fatal error " )
702722 || ( line. hasPrefix ( " / " ) && line. contains ( " .swift: " ) ) // runtime warnings
723+ || line. contains ( " ' started " ) // XCTest: "Test Case '...' started."
724+ || line. contains ( " \" started " ) // Swift Testing: Test "..." started
725+ || line. contains ( " signal code " ) // "Exited with [unexpected] signal code N"
726+ || line. hasPrefix ( " Restarting after " ) // crash confirmation
703727
704728 if !containsRelevant {
705729 return
706730 }
707731
732+ // Track "Test Case '...' started." for crash association
733+ if parseStartedTest ( line) {
734+ return
735+ }
736+
737+ // Signal code: "Exited with [unexpected] signal code N"
738+ if line. contains ( " signal code " ) {
739+ if let lastSpace = line. lastIndex ( of: " " ) {
740+ let codeStr = String ( line [ line. index ( after: lastSpace) ... ] )
741+ pendingSignalCode = Int ( codeStr)
742+ }
743+ return
744+ }
745+
746+ // Crash confirmation: "Restarting after unexpected exit, crash, or test timeout"
747+ if line. hasPrefix ( " Restarting after " ) {
748+ if let testName = lastStartedTestName {
749+ let message : String
750+ if let code = pendingSignalCode {
751+ message = " Crashed (signal \( code) ): last test started before crash "
752+ } else {
753+ message = " Crashed: last test started before crash "
754+ }
755+ let normalizedName = normalizeTestName ( testName)
756+ if !hasSeenSimilarTest( normalizedName) {
757+ failedTests. append ( FailedTest ( test: testName, message: message, file: nil , line: nil ) )
758+ seenTestNames. insert ( normalizedName)
759+ }
760+ }
761+ lastStartedTestName = nil
762+ pendingSignalCode = nil
763+ return
764+ }
765+
708766 // Parse parallel test scheduling lines: [N/TOTAL] Testing Module.Class/method
709767 if line. contains ( " ] Testing " ) , let match = line. firstMatch ( of: Self . parallelTestSchedulingRegex) {
710768 if let _ = Int ( match. 1 ) , let total = Int ( match. 2 ) {
@@ -727,6 +785,11 @@ class OutputParser {
727785 if let failedTest = parseFailedTest ( line) {
728786 let normalizedTestName = normalizeTestName ( failedTest. test)
729787
788+ // Clear lastStartedTestName if this failed test matches
789+ if normalizeTestName ( lastStartedTestName ?? " " ) == normalizedTestName {
790+ lastStartedTestName = nil
791+ }
792+
730793 // Check if we've already seen this test name or a similar one
731794 if !hasSeenSimilarTest( normalizedTestName) {
732795 failedTests. append ( failedTest)
@@ -761,6 +824,21 @@ class OutputParser {
761824 seenErrors. insert ( key)
762825 errors. append ( error)
763826 }
827+ // Fatal error + lastStartedTestName → also create FailedTest
828+ if line. contains ( " Fatal error " ) , let testName = lastStartedTestName {
829+ let normalizedName = normalizeTestName ( testName)
830+ if !hasSeenSimilarTest( normalizedName) {
831+ failedTests. append (
832+ FailedTest (
833+ test: testName,
834+ message: " Crashed (Fatal error): last test started before crash " ,
835+ file: error. file,
836+ line: error. line
837+ )
838+ )
839+ seenTestNames. insert ( normalizedName)
840+ }
841+ }
764842 } else if let warning = parseWarning ( line) {
765843 let key = " \( warning. file ?? " " ) : \( warning. line ?? 0 ) : \( warning. message) "
766844 if !seenWarnings. contains ( key) {
@@ -1008,8 +1086,48 @@ class OutputParser {
10081086 return false
10091087 }
10101088
1089+ // MARK: - Crash Association
1090+
1091+ /// Parses "Test Case '...' started." or "◇ Test "..." started." lines.
1092+ /// Updates `lastStartedTestName` for crash association.
1093+ private func parseStartedTest( _ line: String ) -> Bool {
1094+ // XCTest: Test Case '-[Module.Class testMethod]' started.
1095+ // XCTest parallel: Test case '-[Module.Class testMethod]' started.
1096+ if ( line. hasPrefix ( " Test Case ' " ) || line. hasPrefix ( " Test case ' " ) )
1097+ && line. contains ( " ' started " )
1098+ {
1099+ let prefixLength = 11 // "Test Case '" or "Test case '"
1100+ let startIndex = line. index ( line. startIndex, offsetBy: prefixLength)
1101+ if let endQuote = line. range ( of: " ' started " , range: startIndex ..< line. endIndex) {
1102+ lastStartedTestName = String ( line [ startIndex ..< endQuote. lowerBound] )
1103+ }
1104+ return true
1105+ }
1106+
1107+ // Swift Testing: ◇ Test "shouldCrash()" started.
1108+ // or: ◇ Test functionName() started.
1109+ if line. hasPrefix ( " ◇ Test " ) {
1110+ let afterPrefix = line. index ( line. startIndex, offsetBy: " ◇ Test " . count)
1111+ if let result = extractSwiftTestingName ( from: line, after: afterPrefix) {
1112+ let afterName = line [ result. endIndex... ]
1113+ if afterName. hasPrefix ( " started " ) {
1114+ lastStartedTestName = result. name
1115+ return true
1116+ }
1117+ }
1118+ }
1119+
1120+ return false
1121+ }
1122+
10111123 private func recordPassedTest( named testName: String , duration: Double ? = nil ) {
10121124 let normalizedTestName = normalizeTestName ( testName)
1125+
1126+ // Clear lastStartedTestName if this passed test matches
1127+ if normalizeTestName ( lastStartedTestName ?? " " ) == normalizedTestName {
1128+ lastStartedTestName = nil
1129+ }
1130+
10131131 guard seenPassedTestNames. insert ( normalizedTestName) . inserted else {
10141132 return
10151133 }
0 commit comments