@@ -13,6 +13,19 @@ import (
1313 "github.com/jstemmer/go-junit-report/v2/junit"
1414)
1515
16+ // alertsSuiteName is the JUnit suite name used for structural alerts (data
17+ // races, panics, fatal errors). It is also used by collectSummaryRows to
18+ // distinguish alert entries from regular test failures.
19+ const alertsSuiteName = "ALERTS"
20+
21+ const junitAlertDetailsMaxBytes = 64 * 1024
22+
23+ const (
24+ failureKindFailed = "Failed"
25+ failureKindTimeout = "TIMEOUT"
26+ failureKindCrash = "CRASH"
27+ )
28+
1629type junitReport struct {
1730 junit.Testsuites
1831 path string
@@ -33,6 +46,9 @@ func (j *junitReport) read() error {
3346 return nil
3447}
3548
49+ // generateStatic creates synthetic failures for non-JUnit failure modes such as
50+ // timeouts and crashes. Failure.Message is the canonical failure kind for these
51+ // synthetic rows (for example TIMEOUT or CRASH), and Failure.Data is left empty.
3652func generateStatic (names []string , suffix string , message string ) * junitReport {
3753 var testcases []junit.Testcase
3854 for _ , name := range names {
@@ -77,25 +93,32 @@ func (j *junitReport) appendAlertsSuite(alerts []alert) {
7793 if len (alerts ) == 0 {
7894 return
7995 }
96+
97+ // Convert alerts to JUnit test cases.
8098 var cases []junit.Testcase
8199 for _ , a := range alerts {
82100 name := fmt .Sprintf ("%s: %s" , a .Kind , a .Summary )
83101 if p := primaryTestName (a .Tests ); p != "" {
84102 name = fmt .Sprintf ("%s — in %s" , name , p )
85103 }
86- // Include only test names for context, not the full log details to avoid XML malformation
87- var details string
104+ var sb strings.Builder
105+ if a .Details != "" {
106+ sb .WriteString (truncateAlertDetails (sanitizeXML (a .Details )))
107+ sb .WriteByte ('\n' )
108+ }
88109 if len (a .Tests ) > 0 {
89- details = fmt .Sprintf ( "Detected in tests:\n \t %s" , strings .Join (a .Tests , "\n \t " ))
110+ fmt .Fprintf ( & sb , "Detected in tests:\n \t %s" , strings .Join (a .Tests , "\n \t " ))
90111 }
91- r := & junit.Result {Message : string (a .Kind ), Data : details }
112+ r := & junit.Result {Message : string (a .Kind ), Data : strings . TrimRight ( sb . String (), " \n " ) }
92113 cases = append (cases , junit.Testcase {
93114 Name : name ,
94115 Failure : r ,
95116 })
96117 }
118+
119+ // Append the alerts suite to the report.
97120 suite := junit.Testsuite {
98- Name : "ALERTS" ,
121+ Name : alertsSuiteName ,
99122 Failures : len (cases ),
100123 Tests : len (cases ),
101124 Testcases : cases ,
@@ -105,6 +128,30 @@ func (j *junitReport) appendAlertsSuite(alerts []alert) {
105128 j .Tests += suite .Tests
106129}
107130
131+ // sanitizeXML removes characters that are invalid in XML 1.0. Go's xml.Encoder
132+ // escapes <, >, & etc., but control characters other than \t, \n, \r are not
133+ // legal XML and cause parsers to reject the document.
134+ func sanitizeXML (s string ) string {
135+ return strings .Map (func (r rune ) rune {
136+ if r == '\t' || r == '\n' || r == '\r' {
137+ return r
138+ }
139+ if r < 0x20 || r == 0xFFFE || r == 0xFFFF {
140+ return - 1
141+ }
142+ return r
143+ }, s )
144+ }
145+
146+ // truncateAlertDetails keeps alert payloads from bloating the JUnit artifact.
147+ func truncateAlertDetails (s string ) string {
148+ if len (s ) <= junitAlertDetailsMaxBytes {
149+ return s
150+ }
151+ const marker = "\n ... (truncated) ...\n "
152+ return s [:junitAlertDetailsMaxBytes - len (marker )] + marker
153+ }
154+
108155// dedupeAlerts removes duplicate alerts (e.g., repeated across retries) based
109156// on kind and details while preserving the first-seen order.
110157func dedupeAlerts (alerts []alert ) []alert {
@@ -224,6 +271,19 @@ func mergeReports(reports []*junitReport) (*junitReport, error) {
224271 // Discard test case parents since they provide no value.
225272 continue
226273 }
274+
275+ // Parse failure details from Failure.Data, if present.
276+ if testCase .Failure != nil && testCase .Failure .Data != "" {
277+ if details := parseFailureDetails (testCase .Failure .Data ); details != noFailureDetails {
278+ testCase .Failure .Data = details
279+ }
280+ }
281+
282+ // Normalize ordinary failures to failureKindFailed;
283+ // alert messages keep their specific kind.
284+ if testCase .Failure != nil && suite .Name != alertsSuiteName {
285+ testCase .Failure .Message = failureKindFailed
286+ }
227287 testCase .Name += suffix
228288 newSuite .Testcases = append (newSuite .Testcases , testCase )
229289 }
0 commit comments