View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugin.surefire.report;
20  
21  import java.io.BufferedOutputStream;
22  import java.io.File;
23  import java.io.FilterOutputStream;
24  import java.io.IOException;
25  import java.io.OutputStream;
26  import java.io.OutputStreamWriter;
27  import java.io.PrintWriter;
28  import java.nio.file.Files;
29  import java.util.ArrayList;
30  import java.util.Deque;
31  import java.util.LinkedHashMap;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.Map.Entry;
35  import java.util.StringTokenizer;
36  import java.util.concurrent.ConcurrentLinkedDeque;
37  
38  import org.apache.maven.plugin.surefire.booterclient.output.InPluginProcessDumpSingleton;
39  import org.apache.maven.surefire.api.report.SafeThrowable;
40  import org.apache.maven.surefire.extensions.StatelessReportEventListener;
41  import org.apache.maven.surefire.shared.utils.xml.PrettyPrintXMLWriter;
42  import org.apache.maven.surefire.shared.utils.xml.XMLWriter;
43  
44  import static java.nio.charset.StandardCharsets.UTF_8;
45  import static org.apache.maven.plugin.surefire.report.DefaultReporterFactory.TestResultType;
46  import static org.apache.maven.plugin.surefire.report.FileReporterUtils.stripIllegalFilenameChars;
47  import static org.apache.maven.plugin.surefire.report.ReportEntryType.SKIPPED;
48  import static org.apache.maven.plugin.surefire.report.ReportEntryType.SUCCESS;
49  import static org.apache.maven.surefire.shared.utils.StringUtils.isBlank;
50  import static org.apache.maven.surefire.shared.utils.StringUtils.isNotBlank;
51  
52  // CHECKSTYLE_OFF: LineLength
53  /**
54   * XML format reporter writing to <code>TEST-<i>reportName</i>[-<i>suffix</i>].xml</code> file like written and read
55   * by Ant's <a href="http://ant.apache.org/manual/Tasks/junit.html"><code>&lt;junit&gt;</code></a> and
56   * <a href="http://ant.apache.org/manual/Tasks/junitreport.html"><code>&lt;junitreport&gt;</code></a> tasks,
57   * then supported by many tools like CI servers.
58   * <br>
59   * <pre>&lt;?xml version="1.0" encoding="UTF-8"?&gt;
60   * &lt;testsuite name="<i>suite name</i>" [group="<i>group</i>"] tests="<i>0</i>" failures="<i>0</i>" errors="<i>0</i>" skipped="<i>0</i>" time="<i>{float}</i>"&gt;
61   *  &lt;properties&gt;
62   *    &lt;property name="<i>name</i>" value="<i>value</i>"/&gt;
63   *    [...]
64   *  &lt;/properties&gt;
65   *  &lt;testcase time="<i>{float}</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]"/&gt;
66   *  &lt;testcase time="<i>{float}</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]"&gt;
67   *    &lt;<b>error</b> message="<i>message</i>" type="<i>exception class name</i>"&gt;<i>stacktrace</i>&lt;/error&gt;
68   *    &lt;system-out&gt;<i>system out content (present only if not empty)</i>&lt;/system-out&gt;
69   *    &lt;system-err&gt;<i>system err content (present only if not empty)</i>&lt;/system-err&gt;
70   *  &lt;/testcase&gt;
71   *  &lt;testcase time="<i>{float}</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]"&gt;
72   *    &lt;<b>failure</b> message="<i>message</i>" type="<i>exception class name</i>"&gt;<i>stacktrace</i>&lt;/failure&gt;
73   *    &lt;system-out&gt;<i>system out content (present only if not empty)</i>&lt;/system-out&gt;
74   *    &lt;system-err&gt;<i>system err content (present only if not empty)</i>&lt;/system-err&gt;
75   *  &lt;/testcase&gt;
76   *  &lt;testcase time="<i>{float}</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]"&gt;
77   *    &lt;<b>skipped</b>/&gt;
78   *  &lt;/testcase&gt;
79   *  [...]</pre>
80   *
81   * @author Kristian Rosenvold
82   * @see <a href="https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=115528872">Ant's format enhancement proposal</a>
83   *      (not yet implemented by Ant 1.8.2)
84   * @see <a href="https://github.com/apache/ant/blob/6f68fbdefad521741b37304cfdfc6d8646cf1839/src/main/org/apache/tools/ant/taskdefs/optional/junit/XMLJUnitResultFormatter.java">Ant's <code>XMLJUnitResultFormatter</code></a>
85   */
86  @SuppressWarnings({"javadoc", "checkstyle:javadoctype"})
87  // TODO this is no more stateless due to existence of testClassMethodRunHistoryMap since of 2.19.
88  public class StatelessXmlReporter implements StatelessReportEventListener<WrappedReportEntry, TestSetStats> {
89      private static final float ONE_SECOND = 1000.0f;
90  
91      private static final String XML_INDENT = "  ";
92  
93      private static final String XML_NL = "\n";
94  
95      private final File reportsDirectory;
96  
97      private final String reportNameSuffix;
98  
99      private final boolean trimStackTrace;
100 
101     private final int rerunFailingTestsCount;
102 
103     private final String xsdSchemaLocation;
104 
105     private final String xsdVersion;
106 
107     // Map between test class name and a map between test method name
108     // and the list of runs for each test method
109     private final Map<String, Deque<WrappedReportEntry>> testClassMethodRunHistoryMap;
110 
111     private final boolean phrasedFileName;
112 
113     private final boolean phrasedSuiteName;
114 
115     private final boolean phrasedClassName;
116 
117     private final boolean phrasedMethodName;
118 
119     private final boolean enableOutErrElements;
120 
121     private final boolean enablePropertiesElement;
122 
123     @Deprecated
124     public StatelessXmlReporter(
125             File reportsDirectory,
126             String reportNameSuffix,
127             boolean trimStackTrace,
128             int rerunFailingTestsCount,
129             Map<String, Deque<WrappedReportEntry>> testClassMethodRunHistoryMap,
130             String xsdSchemaLocation,
131             String xsdVersion,
132             boolean phrasedFileName,
133             boolean phrasedSuiteName,
134             boolean phrasedClassName,
135             boolean phrasedMethodName,
136             boolean enableOutErrElements,
137             boolean enablePropertiesElement) {
138         this.reportsDirectory = reportsDirectory;
139         this.reportNameSuffix = reportNameSuffix;
140         this.trimStackTrace = trimStackTrace;
141         this.rerunFailingTestsCount = rerunFailingTestsCount;
142         this.testClassMethodRunHistoryMap = testClassMethodRunHistoryMap;
143         this.xsdSchemaLocation = xsdSchemaLocation;
144         this.xsdVersion = xsdVersion;
145         this.phrasedFileName = phrasedFileName;
146         this.phrasedSuiteName = phrasedSuiteName;
147         this.phrasedClassName = phrasedClassName;
148         this.phrasedMethodName = phrasedMethodName;
149         this.enableOutErrElements = enableOutErrElements;
150         this.enablePropertiesElement = enablePropertiesElement;
151     }
152 
153     @Override
154     public void testSetCompleted(WrappedReportEntry testSetReportEntry, TestSetStats testSetStats) {
155         Map<String, Map<String, List<WrappedReportEntry>>> classMethodStatistics =
156                 arrangeMethodStatistics(testSetReportEntry, testSetStats);
157 
158         // The Java Language Spec:
159         // "Note that the close methods of resources are called in the opposite order of their creation."
160         try (OutputStream outputStream = getOutputStream(testSetReportEntry);
161                 OutputStreamWriter fw = getWriter(outputStream)) {
162             XMLWriter ppw = new PrettyPrintXMLWriter(new PrintWriter(fw), XML_INDENT, XML_NL, UTF_8.name(), null);
163 
164             createTestSuiteElement(ppw, testSetReportEntry, classMethodStatistics); // TestSuite
165 
166             if (enablePropertiesElement) {
167                 showProperties(ppw, testSetReportEntry.getSystemProperties());
168             } else {
169                 boolean hasNonSuccess = false;
170                 for (Map<String, List<WrappedReportEntry>> statistics : classMethodStatistics.values()) {
171                     for (List<WrappedReportEntry> thisMethodRuns : statistics.values()) {
172                         if (thisMethodRuns.stream()
173                                 .anyMatch(entry -> entry.getReportEntryType() != ReportEntryType.SUCCESS)) {
174                             hasNonSuccess = true;
175                             break;
176                         }
177                     }
178                     if (hasNonSuccess) {
179                         break;
180                     }
181                 }
182 
183                 if (hasNonSuccess) {
184                     showProperties(ppw, testSetReportEntry.getSystemProperties());
185                 }
186             }
187 
188             for (Entry<String, Map<String, List<WrappedReportEntry>>> statistics : classMethodStatistics.entrySet()) {
189                 Map<String, List<WrappedReportEntry>> methodStatistics = statistics.getValue();
190                 for (Entry<String, List<WrappedReportEntry>> thisMethodRuns : methodStatistics.entrySet()) {
191                     serializeTestClass(outputStream, fw, ppw, thisMethodRuns.getValue(), methodStatistics);
192                 }
193             }
194 
195             ppw.endElement(); // TestSuite
196         } catch (IOException e) {
197             // It's not a test error.
198             // This method must be sail-safe and errors are in a dump log.
199             // The control flow must not be broken in TestSetRunListener#testSetCompleted.
200             InPluginProcessDumpSingleton.getSingleton().dumpException(e, e.getLocalizedMessage(), reportsDirectory);
201         }
202     }
203 
204     private Map<String, Map<String, List<WrappedReportEntry>>> arrangeMethodStatistics(
205             WrappedReportEntry testSetReportEntry, TestSetStats testSetStats) {
206         Map<String, Map<String, List<WrappedReportEntry>>> classMethodStatistics = new LinkedHashMap<>();
207         for (WrappedReportEntry methodEntry : aggregateCacheFromMultipleReruns(testSetReportEntry, testSetStats)) {
208             String testClassName = methodEntry.getSourceName();
209             Map<String, List<WrappedReportEntry>> stats =
210                     classMethodStatistics.computeIfAbsent(testClassName, k -> new LinkedHashMap<>());
211             String methodName = methodEntry.getName();
212             List<WrappedReportEntry> methodRuns = stats.computeIfAbsent(methodName, k -> new ArrayList<>());
213             methodRuns.add(methodEntry);
214         }
215         return classMethodStatistics;
216     }
217 
218     private Deque<WrappedReportEntry> aggregateCacheFromMultipleReruns(
219             WrappedReportEntry testSetReportEntry, TestSetStats testSetStats) {
220         String suiteClassName = testSetReportEntry.getSourceName();
221         Deque<WrappedReportEntry> methodRunHistory = getAddMethodRunHistoryMap(suiteClassName);
222         methodRunHistory.addAll(testSetStats.getReportEntries());
223         return methodRunHistory;
224     }
225 
226     private void serializeTestClass(
227             OutputStream outputStream,
228             OutputStreamWriter fw,
229             XMLWriter ppw,
230             List<WrappedReportEntry> methodEntries,
231             Map<String, List<WrappedReportEntry>> methodStatistics)
232             throws IOException {
233         if (rerunFailingTestsCount > 0) {
234             serializeTestClassWithRerun(outputStream, fw, ppw, methodEntries, methodStatistics);
235         } else {
236             // rerunFailingTestsCount is smaller than 1, but for some reasons a test could be run
237             // for more than once
238             serializeTestClassWithoutRerun(outputStream, fw, ppw, methodEntries);
239         }
240     }
241 
242     private void serializeTestClassWithoutRerun(
243             OutputStream outputStream, OutputStreamWriter fw, XMLWriter ppw, List<WrappedReportEntry> methodEntries)
244             throws IOException {
245         for (WrappedReportEntry methodEntry : methodEntries) {
246             startTestElement(ppw, methodEntry);
247             if (methodEntry.getReportEntryType() != SUCCESS) {
248                 getTestProblems(
249                         fw,
250                         ppw,
251                         methodEntry,
252                         trimStackTrace,
253                         outputStream,
254                         methodEntry.getReportEntryType().getXmlTag(),
255                         false);
256             }
257             if (methodEntry.getReportEntryType() != SUCCESS || enableOutErrElements) {
258                 createOutErrElements(fw, ppw, methodEntry, outputStream);
259             }
260             ppw.endElement();
261         }
262     }
263 
264     private void serializeTestClassWithRerun(
265             OutputStream outputStream,
266             OutputStreamWriter fw,
267             XMLWriter ppw,
268             List<WrappedReportEntry> methodEntries,
269             Map<String, List<WrappedReportEntry>> methodStatistics)
270             throws IOException {
271         WrappedReportEntry firstMethodEntry = methodEntries.get(0);
272 
273         TestResultType resultType =
274                 getTestResultTypeWithBeforeAllHandling(firstMethodEntry.getName(), methodEntries, methodStatistics);
275 
276         switch (resultType) {
277             case SUCCESS:
278                 for (WrappedReportEntry methodEntry : methodEntries) {
279                     if (methodEntry.getReportEntryType() == SUCCESS) {
280                         startTestElement(ppw, methodEntry);
281                         ppw.endElement();
282                     }
283                 }
284                 break;
285             case ERROR:
286             case FAILURE:
287                 // When rerunFailingTestsCount is set to larger than 0
288                 startTestElement(ppw, firstMethodEntry);
289                 boolean firstRun = true;
290                 for (WrappedReportEntry singleRunEntry : methodEntries) {
291                     if (firstRun) {
292                         firstRun = false;
293                         getTestProblems(
294                                 fw,
295                                 ppw,
296                                 singleRunEntry,
297                                 trimStackTrace,
298                                 outputStream,
299                                 singleRunEntry.getReportEntryType().getXmlTag(),
300                                 false);
301                         createOutErrElements(fw, ppw, singleRunEntry, outputStream);
302                     } else if (singleRunEntry.getReportEntryType() == SKIPPED) {
303                         // The version 3.1.0 should produce a new XSD schema with version 3.1.0, see SUREFIRE-1986,
304                         // and the XSD schema should add a new element "rerunSkipped"
305                         // then ReportEntryType should update the enum to SKIPPED( "skipped", "", "rerunSkipped" ).
306                         // The teams should be notified - Jenkins reports.
307                         addCommentElementTestCase("a skipped test execution in re-run phase", fw, ppw, outputStream);
308                     } else {
309                         getTestProblems(
310                                 fw,
311                                 ppw,
312                                 singleRunEntry,
313                                 trimStackTrace,
314                                 outputStream,
315                                 singleRunEntry.getReportEntryType().getRerunXmlTag(),
316                                 true);
317                     }
318                 }
319                 ppw.endElement();
320                 break;
321             case FLAKE:
322                 WrappedReportEntry successful = null;
323                 // Get the run time of the first successful run
324                 for (WrappedReportEntry singleRunEntry : methodEntries) {
325                     if (singleRunEntry.getReportEntryType() == SUCCESS) {
326                         successful = singleRunEntry;
327                         break;
328                     }
329                 }
330                 WrappedReportEntry firstOrSuccessful = successful == null ? methodEntries.get(0) : successful;
331                 startTestElement(ppw, firstOrSuccessful);
332                 for (WrappedReportEntry singleRunEntry : methodEntries) {
333                     if (singleRunEntry.getReportEntryType() != SUCCESS) {
334                         getTestProblems(
335                                 fw,
336                                 ppw,
337                                 singleRunEntry,
338                                 trimStackTrace,
339                                 outputStream,
340                                 singleRunEntry.getReportEntryType().getFlakyXmlTag(),
341                                 true);
342                     }
343                 }
344                 ppw.endElement();
345                 break;
346             case SKIPPED:
347                 startTestElement(ppw, firstMethodEntry);
348                 getTestProblems(
349                         fw,
350                         ppw,
351                         firstMethodEntry,
352                         trimStackTrace,
353                         outputStream,
354                         firstMethodEntry.getReportEntryType().getXmlTag(),
355                         false);
356                 ppw.endElement();
357                 break;
358             default:
359                 throw new IllegalStateException("Get unknown test result type");
360         }
361     }
362 
363     /**
364      * Clean testClassMethodRunHistoryMap
365      */
366     public void cleanTestHistoryMap() {
367         testClassMethodRunHistoryMap.clear();
368     }
369 
370     /**
371      * Get the result of a test from a list of its runs in WrappedReportEntry
372      *
373      * @param methodEntryList the list of runs for a given test
374      * @return the TestResultType for the given test
375      */
376     private TestResultType getTestResultType(List<WrappedReportEntry> methodEntryList) {
377         List<ReportEntryType> testResultTypeList = new ArrayList<>();
378         for (WrappedReportEntry singleRunEntry : methodEntryList) {
379             testResultTypeList.add(singleRunEntry.getReportEntryType());
380         }
381 
382         return DefaultReporterFactory.getTestResultType(testResultTypeList, rerunFailingTestsCount);
383     }
384 
385     /**
386      * Determines the final result type for a test method, applying special handling for @BeforeAll failures.
387      * If a @BeforeAll fails but any actual test methods succeed, it's classified as a FLAKE.
388      *
389      * @param methodName the name of the test method (null or "null" for @BeforeAll)
390      * @param methodRuns the list of runs for this method
391      * @param methodStatistics all method statistics for the test class
392      * @return the final TestResultType
393      */
394     private TestResultType getTestResultTypeWithBeforeAllHandling(
395             String methodName,
396             List<WrappedReportEntry> methodRuns,
397             Map<String, List<WrappedReportEntry>> methodStatistics) {
398         TestResultType resultType = getTestResultType(methodRuns);
399 
400         // Special handling for @BeforeAll failures (null method name or method name is "null")
401         // If @BeforeAll failed but any actual test methods succeeded, treat it as a flake
402         if ((methodName == null || methodName.equals("null"))
403                 && (resultType == TestResultType.ERROR || resultType == TestResultType.FAILURE)) {
404             // Check if any actual test methods (non-null and not "null" names) succeeded
405             boolean hasSuccessfulTestMethods = methodStatistics.entrySet().stream()
406                     .filter(entry ->
407                             entry.getKey() != null && !entry.getKey().equals("null")) // Only actual test methods
408                     .anyMatch(entry -> entry.getValue().stream()
409                             .anyMatch(reportEntry -> reportEntry.getReportEntryType() == SUCCESS));
410 
411             if (hasSuccessfulTestMethods) {
412                 resultType = TestResultType.FLAKE;
413             }
414         }
415 
416         return resultType;
417     }
418 
419     private Deque<WrappedReportEntry> getAddMethodRunHistoryMap(String testClassName) {
420         Deque<WrappedReportEntry> methodRunHistory = testClassMethodRunHistoryMap.get(testClassName);
421         if (methodRunHistory == null) {
422             methodRunHistory = new ConcurrentLinkedDeque<>();
423             testClassMethodRunHistoryMap.put(testClassName == null ? "null" : testClassName, methodRunHistory);
424         }
425         return methodRunHistory;
426     }
427 
428     private OutputStream getOutputStream(WrappedReportEntry testSetReportEntry) throws IOException {
429         File reportFile = getReportFile(testSetReportEntry);
430         File reportDir = reportFile.getParentFile();
431         //noinspection ResultOfMethodCallIgnored
432         reportFile.delete();
433         //noinspection ResultOfMethodCallIgnored
434         reportDir.mkdirs();
435         return new BufferedOutputStream(Files.newOutputStream(reportFile.toPath()), 64 * 1024);
436     }
437 
438     private static OutputStreamWriter getWriter(OutputStream fos) {
439         return new OutputStreamWriter(fos, UTF_8);
440     }
441 
442     private File getReportFile(WrappedReportEntry report) {
443         String reportName = "TEST-" + (phrasedFileName ? report.getReportSourceName() : report.getSourceName());
444         String customizedReportName = isBlank(reportNameSuffix) ? reportName : reportName + "-" + reportNameSuffix;
445         return new File(reportsDirectory, stripIllegalFilenameChars(customizedReportName + ".xml"));
446     }
447 
448     private void startTestElement(XMLWriter ppw, WrappedReportEntry report) throws IOException {
449         ppw.startElement("testcase");
450         String name = phrasedMethodName ? report.getReportName() : report.getName();
451         ppw.addAttribute("name", name == null ? "" : extraEscapeAttribute(name));
452 
453         if (report.getGroup() != null) {
454             ppw.addAttribute("group", report.getGroup());
455         }
456 
457         String className = phrasedClassName
458                 ? report.getReportSourceName(reportNameSuffix)
459                 : report.getSourceText() != null ? report.getSourceText() : report.getSourceName(reportNameSuffix);
460         if (className != null) {
461             ppw.addAttribute("classname", extraEscapeAttribute(className));
462         }
463 
464         if (report.getElapsed() != null) {
465             ppw.addAttribute("time", String.valueOf(report.getElapsed() / ONE_SECOND));
466         }
467     }
468 
469     private void createTestSuiteElement(
470             XMLWriter ppw,
471             WrappedReportEntry report,
472             Map<String, Map<String, List<WrappedReportEntry>>> classMethodStatistics)
473             throws IOException {
474         ppw.startElement("testsuite");
475 
476         ppw.addAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
477         ppw.addAttribute("xsi:noNamespaceSchemaLocation", xsdSchemaLocation);
478         ppw.addAttribute("version", xsdVersion);
479 
480         String reportName = phrasedSuiteName
481                 ? report.getReportSourceName(reportNameSuffix)
482                 : report.getSourceName(reportNameSuffix);
483         ppw.addAttribute("name", reportName == null ? "" : extraEscapeAttribute(reportName));
484 
485         if (report.getGroup() != null) {
486             ppw.addAttribute("group", report.getGroup());
487         }
488 
489         if (report.getElapsed() != null) {
490             ppw.addAttribute("time", String.valueOf(report.getElapsed() / ONE_SECOND));
491         }
492 
493         // Count actual unique test methods and their final results from classMethodStatistics (accumulated across
494         // reruns)
495         int actualTestCount = 0;
496         int errors = 0;
497         int failures = 0;
498         int skipped = 0;
499         int flakes = 0;
500 
501         for (Map<String, List<WrappedReportEntry>> methodStats : classMethodStatistics.values()) {
502             actualTestCount += methodStats.size();
503             for (Map.Entry<String, List<WrappedReportEntry>> methodEntry : methodStats.entrySet()) {
504                 String methodName = methodEntry.getKey();
505                 List<WrappedReportEntry> methodRuns = methodEntry.getValue();
506                 TestResultType resultType = getTestResultTypeWithBeforeAllHandling(methodName, methodRuns, methodStats);
507 
508                 switch (resultType) {
509                     case ERROR:
510                         errors++;
511                         break;
512                     case FAILURE:
513                         failures++;
514                         break;
515                     case SKIPPED:
516                         skipped++;
517                         break;
518                     case FLAKE:
519                         flakes++;
520                         break;
521                     case SUCCESS:
522                     default:
523                         break;
524                 }
525             }
526         }
527 
528         ppw.addAttribute("tests", String.valueOf(actualTestCount));
529         ppw.addAttribute("errors", String.valueOf(errors));
530         ppw.addAttribute("skipped", String.valueOf(skipped));
531         ppw.addAttribute("failures", String.valueOf(failures));
532         ppw.addAttribute("flakes", String.valueOf(flakes));
533     }
534 
535     private static void getTestProblems(
536             OutputStreamWriter outputStreamWriter,
537             XMLWriter ppw,
538             WrappedReportEntry report,
539             boolean trimStackTrace,
540             OutputStream fw,
541             String testErrorType,
542             boolean createNestedOutErrElements)
543             throws IOException {
544         ppw.startElement(testErrorType);
545 
546         String stackTrace = report.getStackTrace(trimStackTrace);
547 
548         if (report.getMessage() != null && !report.getMessage().isEmpty()) {
549             ppw.addAttribute("message", extraEscapeAttribute(report.getMessage()));
550         }
551 
552         if (report.getStackTraceWriter() != null) {
553             //noinspection ThrowableResultOfMethodCallIgnored
554             SafeThrowable t = report.getStackTraceWriter().getThrowable();
555             if (t != null) {
556                 if (t.getMessage() != null) {
557                     int delimiter = stackTrace.indexOf(":");
558                     String type = delimiter == -1 ? stackTrace : stackTrace.substring(0, delimiter);
559                     ppw.addAttribute("type", type);
560                 } else {
561                     if (isNotBlank(stackTrace)) {
562                         ppw.addAttribute("type", new StringTokenizer(stackTrace).nextToken());
563                     }
564                 }
565             }
566         }
567 
568         /* This structure is inconsistent due to bad legacy design choices for the XML schema.
569          * Ideally, all elements would be complex and strackTrace would have its own element.
570          * See SUREFIRE-2230 for details to how improve and unify the schema in the future.
571          */
572         if (createNestedOutErrElements) {
573             ppw.startElement("stackTrace");
574             if (stackTrace != null) {
575                 extraEscapeElementValue(stackTrace, outputStreamWriter, ppw, fw);
576             }
577             ppw.endElement();
578 
579             createOutErrElements(outputStreamWriter, ppw, report, fw);
580         } else if (stackTrace != null) {
581             extraEscapeElementValue(stackTrace, outputStreamWriter, ppw, fw);
582         }
583 
584         ppw.endElement(); // entry type
585     }
586 
587     // Create system-out and system-err elements
588     private static void createOutErrElements(
589             OutputStreamWriter outputStreamWriter, XMLWriter ppw, WrappedReportEntry report, OutputStream fw)
590             throws IOException {
591         EncodingOutputStream eos = new EncodingOutputStream(fw);
592         addOutputStreamElement(outputStreamWriter, eos, ppw, report.getStdout(), "system-out");
593         addOutputStreamElement(outputStreamWriter, eos, ppw, report.getStdErr(), "system-err");
594     }
595 
596     private static void addOutputStreamElement(
597             OutputStreamWriter outputStreamWriter,
598             EncodingOutputStream eos,
599             XMLWriter xmlWriter,
600             Utf8RecodingDeferredFileOutputStream utf8RecodingDeferredFileOutputStream,
601             String name)
602             throws IOException {
603         if (utf8RecodingDeferredFileOutputStream != null && utf8RecodingDeferredFileOutputStream.getByteCount() > 0) {
604             xmlWriter.startElement(name);
605             xmlWriter.writeText(""); // Cheat sax to emit element
606             outputStreamWriter.flush();
607             eos.getUnderlying().write(ByteConstantsHolder.CDATA_START_BYTES); // emit cdata
608             utf8RecodingDeferredFileOutputStream.writeTo(eos);
609             utf8RecodingDeferredFileOutputStream.free();
610             eos.getUnderlying().write(ByteConstantsHolder.CDATA_END_BYTES);
611             eos.flush();
612             xmlWriter.endElement();
613         }
614     }
615 
616     /**
617      * Adds system properties to the XML report.
618      * <br>
619      *
620      * @param xmlWriter The test suite to report to
621      */
622     private static void showProperties(XMLWriter xmlWriter, Map<String, String> systemProperties) throws IOException {
623         xmlWriter.startElement("properties");
624         for (final Entry<String, String> entry : systemProperties.entrySet()) {
625             final String key = entry.getKey();
626             String value = entry.getValue();
627 
628             if (value == null) {
629                 value = "null";
630             }
631 
632             xmlWriter.startElement("property");
633 
634             xmlWriter.addAttribute("name", key);
635 
636             xmlWriter.addAttribute("value", extraEscapeAttribute(value));
637 
638             xmlWriter.endElement();
639         }
640         xmlWriter.endElement();
641     }
642 
643     /**
644      * Handle stuff that may pop up in java that is not legal in xml.
645      *
646      * @param message   The string
647      * @return The escaped string or returns itself if all characters are legal
648      */
649     private static String extraEscapeAttribute(String message) {
650         // Someday convert to xml 1.1 which handles everything but 0 inside string
651         return containsEscapesIllegalXml10(message) ? escapeXml(message, true) : message;
652     }
653 
654     /**
655      * Writes escaped string or the message within CDATA if all characters are legal.
656      *
657      * @param message   The string
658      */
659     private static void extraEscapeElementValue(
660             String message, OutputStreamWriter outputStreamWriter, XMLWriter xmlWriter, OutputStream fw)
661             throws IOException {
662         // Someday convert to xml 1.1 which handles everything but 0 inside string
663         if (containsEscapesIllegalXml10(message)) {
664             xmlWriter.writeText(escapeXml(message, false));
665         } else {
666             EncodingOutputStream eos = new EncodingOutputStream(fw);
667             xmlWriter.writeText(""); // Cheat sax to emit element
668             outputStreamWriter.flush();
669             eos.getUnderlying().write(ByteConstantsHolder.CDATA_START_BYTES);
670             eos.write(message.getBytes(UTF_8));
671             eos.getUnderlying().write(ByteConstantsHolder.CDATA_END_BYTES);
672             eos.flush();
673         }
674     }
675 
676     // todo: SUREFIRE-1986
677     private static void addCommentElementTestCase(
678             String comment, OutputStreamWriter outputStreamWriter, XMLWriter xmlWriter, OutputStream fw)
679             throws IOException {
680         xmlWriter.writeText(""); // Cheat sax to emit element
681         outputStreamWriter.flush();
682         fw.write(XML_NL.getBytes(UTF_8));
683         fw.write(XML_INDENT.getBytes(UTF_8));
684         fw.write(XML_INDENT.getBytes(UTF_8));
685         fw.write(ByteConstantsHolder.COMMENT_START);
686         fw.write(comment.getBytes(UTF_8));
687         fw.write(ByteConstantsHolder.COMMENT_END);
688         fw.write(XML_NL.getBytes(UTF_8));
689         fw.write(XML_INDENT.getBytes(UTF_8));
690         fw.flush();
691     }
692 
693     private static final class EncodingOutputStream extends FilterOutputStream {
694         private int c1;
695 
696         private int c2;
697 
698         EncodingOutputStream(OutputStream out) {
699             super(out);
700         }
701 
702         OutputStream getUnderlying() {
703             return out;
704         }
705 
706         private boolean isCdataEndBlock(int c) {
707             return c1 == ']' && c2 == ']' && c == '>';
708         }
709 
710         @Override
711         public void write(int b) throws IOException {
712             if (isCdataEndBlock(b)) {
713                 out.write(ByteConstantsHolder.CDATA_ESCAPE_STRING_BYTES);
714             } else if (isIllegalEscape(b)) {
715                 // uh-oh!  This character is illegal in XML 1.0!
716                 // http://www.w3.org/TR/1998/REC-xml-19980210#charsets
717                 // we're going to deliberately doubly-XML escape it...
718                 // there's nothing better we can do! :-(
719                 // SUREFIRE-456
720                 out.write(ByteConstantsHolder.AMP_BYTES);
721                 out.write(String.valueOf(b).getBytes(UTF_8));
722                 out.write(';'); // & Will be encoded to amp inside xml encodingSHO
723             } else {
724                 out.write(b);
725             }
726             c1 = c2;
727             c2 = b;
728         }
729     }
730 
731     private static boolean containsEscapesIllegalXml10(String message) {
732         int size = message.length();
733         for (int i = 0; i < size; i++) {
734             if (isIllegalEscape(message.charAt(i))) {
735                 return true;
736             }
737         }
738         return false;
739     }
740 
741     private static boolean isIllegalEscape(char c) {
742         return isIllegalEscape((int) c);
743     }
744 
745     private static boolean isIllegalEscape(int c) {
746         return c >= 0 && c < 32 && c != '\n' && c != '\r' && c != '\t';
747     }
748 
749     /**
750      * escape for XML 1.0
751      *
752      * @param text      The string
753      * @param attribute true if the escaped value is inside an attribute
754      * @return The escaped string
755      */
756     private static String escapeXml(String text, boolean attribute) {
757         StringBuilder sb = new StringBuilder(text.length() * 2);
758         for (int i = 0; i < text.length(); i++) {
759             char c = text.charAt(i);
760             if (isIllegalEscape(c)) {
761                 // uh-oh!  This character is illegal in XML 1.0!
762                 // http://www.w3.org/TR/1998/REC-xml-19980210#charsets
763                 // we're going to deliberately doubly-XML escape it...
764                 // there's nothing better we can do! :-(
765                 // SUREFIRE-456
766                 sb.append(attribute ? "&#" : "&amp#")
767                         .append((int) c)
768                         .append(';'); // & Will be encoded to amp inside xml encodingSHO
769             } else {
770                 sb.append(c);
771             }
772         }
773         return sb.toString();
774     }
775 
776     private static final class ByteConstantsHolder {
777         private static final byte[] CDATA_START_BYTES = "<![CDATA[".getBytes(UTF_8);
778 
779         private static final byte[] CDATA_END_BYTES = "]]>".getBytes(UTF_8);
780 
781         private static final byte[] CDATA_ESCAPE_STRING_BYTES = "]]><![CDATA[>".getBytes(UTF_8);
782 
783         private static final byte[] AMP_BYTES = "&amp#".getBytes(UTF_8);
784 
785         private static final byte[] COMMENT_START = "<!-- ".getBytes(UTF_8);
786 
787         private static final byte[] COMMENT_END = " --> ".getBytes(UTF_8);
788     }
789 }