View Javadoc
1   package org.apache.maven.plugin.surefire.report;
2   
3   /*
4    * Licensed to the Apache Software Foundation (ASF) under one
5    * or more contributor license agreements.  See the NOTICE file
6    * distributed with this work for additional information
7    * regarding copyright ownership.  The ASF licenses this file
8    * to you under the Apache License, Version 2.0 (the
9    * "License"); you may not use this file except in compliance
10   * with the License.  You may obtain a copy of the License at
11   *
12   *     http://www.apache.org/licenses/LICENSE-2.0
13   *
14   * Unless required by applicable law or agreed to in writing,
15   * software distributed under the License is distributed on an
16   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17   * KIND, either express or implied.  See the License for the
18   * specific language governing permissions and limitations
19   * under the License.
20   */
21  
22  import org.apache.maven.shared.utils.xml.PrettyPrintXMLWriter;
23  import org.apache.maven.shared.utils.xml.XMLWriter;
24  import org.apache.maven.surefire.report.ReportEntry;
25  import org.apache.maven.surefire.report.ReporterException;
26  import org.apache.maven.surefire.report.SafeThrowable;
27  import org.apache.maven.surefire.util.internal.StringUtils;
28  
29  import java.io.BufferedOutputStream;
30  import java.io.File;
31  import java.io.FileOutputStream;
32  import java.io.FilterOutputStream;
33  import java.io.IOException;
34  import java.io.OutputStream;
35  import java.io.OutputStreamWriter;
36  import java.util.ArrayList;
37  import java.util.Collections;
38  import java.util.LinkedHashMap;
39  import java.util.List;
40  import java.util.Map;
41  import java.util.Map.Entry;
42  import java.util.StringTokenizer;
43  
44  import static org.apache.commons.io.IOUtils.closeQuietly;
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.surefire.util.internal.StringUtils.UTF_8;
48  import static org.apache.maven.surefire.util.internal.StringUtils.isBlank;
49  
50  @SuppressWarnings( { "javadoc", "checkstyle:javadoctype" } )
51  // CHECKSTYLE_OFF: LineLength
52  /**
53   * XML format reporter writing to <code>TEST-<i>reportName</i>[-<i>suffix</i>].xml</code> file like written and read
54   * by Ant's <a href="http://ant.apache.org/manual/Tasks/junit.html"><code>&lt;junit&gt;</code></a> and
55   * <a href="http://ant.apache.org/manual/Tasks/junitreport.html"><code>&lt;junitreport&gt;</code></a> tasks,
56   * then supported by many tools like CI servers.
57   * <br>
58   * <pre>&lt;?xml version="1.0" encoding="UTF-8"?>
59   * &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>0,###.###</i>">
60   *  &lt;properties>
61   *    &lt;property name="<i>name</i>" value="<i>value</i>"/>
62   *    [...]
63   *  &lt;/properties>
64   *  &lt;testcase time="<i>0,###.###</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]"/>
65   *  &lt;testcase time="<i>0,###.###</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]">
66   *    &lt;<b>error</b> message="<i>message</i>" type="<i>exception class name</i>"><i>stacktrace</i>&lt;/error>
67   *    &lt;system-out><i>system out content (present only if not empty)</i>&lt;/system-out>
68   *    &lt;system-err><i>system err content (present only if not empty)</i>&lt;/system-err>
69   *  &lt;/testcase>
70   *  &lt;testcase time="<i>0,###.###</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]">
71   *    &lt;<b>failure</b> message="<i>message</i>" type="<i>exception class name</i>"><i>stacktrace</i>&lt;/failure>
72   *    &lt;system-out><i>system out content (present only if not empty)</i>&lt;/system-out>
73   *    &lt;system-err><i>system err content (present only if not empty)</i>&lt;/system-err>
74   *  &lt;/testcase>
75   *  &lt;testcase time="<i>0,###.###</i>" name="<i>test name</i> [classname="<i>class name</i>"] [group="<i>group</i>"]">
76   *    &lt;<b>skipped</b>/>
77   *  &lt;/testcase>
78   *  [...]</pre>
79   *
80   * @author Kristian Rosenvold
81   * @see <a href="http://wiki.apache.org/ant/Proposals/EnhancedTestReports">Ant's format enhancement proposal</a>
82   *      (not yet implemented by Ant 1.8.2)
83   */
84  public class StatelessXmlReporter
85  {
86      private final File reportsDirectory;
87  
88      private final String reportNameSuffix;
89  
90      private final boolean trimStackTrace;
91  
92      private final int rerunFailingTestsCount;
93  
94      private final String xsdSchemaLocation;
95  
96      // Map between test class name and a map between test method name
97      // and the list of runs for each test method
98      private final Map<String, Map<String, List<WrappedReportEntry>>> testClassMethodRunHistoryMap;
99  
100     public StatelessXmlReporter( File reportsDirectory, String reportNameSuffix, boolean trimStackTrace,
101                                  int rerunFailingTestsCount,
102                                  Map<String, Map<String, List<WrappedReportEntry>>> testClassMethodRunHistoryMap,
103                                  String xsdSchemaLocation )
104     {
105         this.reportsDirectory = reportsDirectory;
106         this.reportNameSuffix = reportNameSuffix;
107         this.trimStackTrace = trimStackTrace;
108         this.rerunFailingTestsCount = rerunFailingTestsCount;
109         this.testClassMethodRunHistoryMap = testClassMethodRunHistoryMap;
110         this.xsdSchemaLocation = xsdSchemaLocation;
111     }
112 
113     public void testSetCompleted( WrappedReportEntry testSetReportEntry, TestSetStats testSetStats )
114     {
115         String testClassName = testSetReportEntry.getName();
116 
117         Map<String, List<WrappedReportEntry>> methodRunHistoryMap = getAddMethodRunHistoryMap( testClassName );
118 
119         // Update testClassMethodRunHistoryMap
120         for ( WrappedReportEntry methodEntry : testSetStats.getReportEntries() )
121         {
122             getAddMethodEntryList( methodRunHistoryMap, methodEntry );
123         }
124 
125         OutputStream outputStream = getOutputStream( testSetReportEntry );
126         OutputStreamWriter fw = getWriter( outputStream );
127         try
128         {
129             XMLWriter ppw = new PrettyPrintXMLWriter( fw );
130             ppw.setEncoding( StringUtils.UTF_8.name() );
131 
132             createTestSuiteElement( ppw, testSetReportEntry, testSetStats, testSetReportEntry.elapsedTimeAsString() );
133 
134             showProperties( ppw, testSetReportEntry.getSystemProperties() );
135 
136             // Iterate through all the test methods in the test class
137             for ( Entry<String, List<WrappedReportEntry>> entry : methodRunHistoryMap.entrySet() )
138             {
139                 List<WrappedReportEntry> methodEntryList = entry.getValue();
140                 if ( methodEntryList == null )
141                 {
142                     throw new IllegalStateException( "Get null test method run history" );
143                 }
144 
145                 if ( !methodEntryList.isEmpty() )
146                 {
147                     if ( rerunFailingTestsCount > 0 )
148                     {
149                         TestResultType resultType = getTestResultType( methodEntryList );
150                         switch ( resultType )
151                         {
152                             case success:
153                                 for ( WrappedReportEntry methodEntry : methodEntryList )
154                                 {
155                                     if ( methodEntry.getReportEntryType() == ReportEntryType.SUCCESS )
156                                     {
157                                         startTestElement( ppw, methodEntry, reportNameSuffix,
158                                                           methodEntryList.get( 0 ).elapsedTimeAsString() );
159                                         ppw.endElement();
160                                     }
161                                 }
162                                 break;
163                             case error:
164                             case failure:
165                                 // When rerunFailingTestsCount is set to larger than 0
166                                 startTestElement( ppw, methodEntryList.get( 0 ), reportNameSuffix,
167                                                   methodEntryList.get( 0 ).elapsedTimeAsString() );
168                                 boolean firstRun = true;
169                                 for ( WrappedReportEntry singleRunEntry : methodEntryList )
170                                 {
171                                     if ( firstRun )
172                                     {
173                                         firstRun = false;
174                                         getTestProblems( fw, ppw, singleRunEntry, trimStackTrace, outputStream,
175                                                          singleRunEntry.getReportEntryType().getXmlTag(), false );
176                                         createOutErrElements( fw, ppw, singleRunEntry, outputStream );
177                                     }
178                                     else
179                                     {
180                                         getTestProblems( fw, ppw, singleRunEntry, trimStackTrace, outputStream,
181                                                          singleRunEntry.getReportEntryType().getRerunXmlTag(), true );
182                                     }
183                                 }
184                                 ppw.endElement();
185                                 break;
186                             case flake:
187                                 String runtime = "";
188                                 // Get the run time of the first successful run
189                                 for ( WrappedReportEntry singleRunEntry : methodEntryList )
190                                 {
191                                     if ( singleRunEntry.getReportEntryType() == ReportEntryType.SUCCESS )
192                                     {
193                                         runtime = singleRunEntry.elapsedTimeAsString();
194                                         break;
195                                     }
196                                 }
197                                 startTestElement( ppw, methodEntryList.get( 0 ), reportNameSuffix, runtime );
198                                 for ( WrappedReportEntry singleRunEntry : methodEntryList )
199                                 {
200                                     if ( singleRunEntry.getReportEntryType() != ReportEntryType.SUCCESS )
201                                     {
202                                         getTestProblems( fw, ppw, singleRunEntry, trimStackTrace, outputStream,
203                                                          singleRunEntry.getReportEntryType().getFlakyXmlTag(), true );
204                                     }
205                                 }
206                                 ppw.endElement();
207 
208                                 break;
209                             case skipped:
210                                 startTestElement( ppw, methodEntryList.get( 0 ), reportNameSuffix,
211                                                   methodEntryList.get( 0 ).elapsedTimeAsString() );
212                                 getTestProblems( fw, ppw, methodEntryList.get( 0 ), trimStackTrace, outputStream,
213                                                  methodEntryList.get( 0 ).getReportEntryType().getXmlTag(), false );
214                                 ppw.endElement();
215                                 break;
216                             default:
217                                 throw new IllegalStateException( "Get unknown test result type" );
218                         }
219                     }
220                     else
221                     {
222                         // rerunFailingTestsCount is smaller than 1, but for some reasons a test could be run
223                         // for more than once
224                         for ( WrappedReportEntry methodEntry : methodEntryList )
225                         {
226                             startTestElement( ppw, methodEntry, reportNameSuffix, methodEntry.elapsedTimeAsString() );
227                             if ( methodEntry.getReportEntryType() != ReportEntryType.SUCCESS )
228                             {
229                                 getTestProblems( fw, ppw, methodEntry, trimStackTrace, outputStream,
230                                                  methodEntry.getReportEntryType().getXmlTag(), false );
231                                 createOutErrElements( fw, ppw, methodEntry, outputStream );
232                             }
233                             ppw.endElement();
234                         }
235                     }
236                 }
237             }
238             ppw.endElement(); // TestSuite
239         }
240         finally
241         {
242             closeQuietly( fw );
243         }
244     }
245 
246     /**
247      * Clean testClassMethodRunHistoryMap
248      */
249     public void cleanTestHistoryMap()
250     {
251         testClassMethodRunHistoryMap.clear();
252     }
253 
254     /**
255      * Get the result of a test from a list of its runs in WrappedReportEntry
256      *
257      * @param methodEntryList the list of runs for a given test
258      * @return the TestResultType for the given test
259      */
260     private TestResultType getTestResultType( List<WrappedReportEntry> methodEntryList )
261     {
262         List<ReportEntryType> testResultTypeList = new ArrayList<ReportEntryType>();
263         for ( WrappedReportEntry singleRunEntry : methodEntryList )
264         {
265             testResultTypeList.add( singleRunEntry.getReportEntryType() );
266         }
267 
268         return DefaultReporterFactory.getTestResultType( testResultTypeList, rerunFailingTestsCount );
269     }
270 
271     private Map<String, List<WrappedReportEntry>> getAddMethodRunHistoryMap( String testClassName )
272     {
273         Map<String, List<WrappedReportEntry>> methodRunHistoryMap = testClassMethodRunHistoryMap.get( testClassName );
274         if ( methodRunHistoryMap == null )
275         {
276             methodRunHistoryMap = Collections.synchronizedMap( new LinkedHashMap<String, List<WrappedReportEntry>>() );
277             testClassMethodRunHistoryMap.put( testClassName, methodRunHistoryMap );
278         }
279         return methodRunHistoryMap;
280     }
281 
282     private OutputStream getOutputStream( WrappedReportEntry testSetReportEntry )
283     {
284         File reportFile = getReportFile( testSetReportEntry, reportsDirectory, reportNameSuffix );
285 
286         File reportDir = reportFile.getParentFile();
287 
288         //noinspection ResultOfMethodCallIgnored
289         reportDir.mkdirs();
290 
291         try
292         {
293             return new BufferedOutputStream( new FileOutputStream( reportFile ), 16 * 1024 );
294         }
295         catch ( Exception e )
296         {
297             throw new ReporterException( "When writing report", e );
298         }
299     }
300 
301     private static OutputStreamWriter getWriter( OutputStream fos )
302     {
303         return new OutputStreamWriter( fos, UTF_8 );
304     }
305 
306     private static void getAddMethodEntryList( Map<String, List<WrappedReportEntry>> methodRunHistoryMap,
307                                                WrappedReportEntry methodEntry )
308     {
309         List<WrappedReportEntry> methodEntryList = methodRunHistoryMap.get( methodEntry.getName() );
310         if ( methodEntryList == null )
311         {
312             methodEntryList = new ArrayList<WrappedReportEntry>();
313             methodRunHistoryMap.put( methodEntry.getName(), methodEntryList );
314         }
315         methodEntryList.add( methodEntry );
316     }
317 
318     private static File getReportFile( ReportEntry report, File reportsDirectory, String reportNameSuffix )
319     {
320         String reportName = "TEST-" + report.getName();
321         String customizedReportName = isBlank( reportNameSuffix ) ? reportName : reportName + "-" + reportNameSuffix;
322         return new File( reportsDirectory, stripIllegalFilenameChars( customizedReportName + ".xml" ) );
323     }
324 
325     private static void startTestElement( XMLWriter ppw, WrappedReportEntry report, String reportNameSuffix,
326                                           String timeAsString )
327     {
328         ppw.startElement( "testcase" );
329         ppw.addAttribute( "name", report.getReportName() );
330         if ( report.getGroup() != null )
331         {
332             ppw.addAttribute( "group", report.getGroup() );
333         }
334         if ( report.getSourceName() != null )
335         {
336             if ( reportNameSuffix != null && !reportNameSuffix.isEmpty() )
337             {
338                 ppw.addAttribute( "classname", report.getSourceName() + "(" + reportNameSuffix + ")" );
339             }
340             else
341             {
342                 ppw.addAttribute( "classname", report.getSourceName() );
343             }
344         }
345         ppw.addAttribute( "time", timeAsString );
346     }
347 
348     private void createTestSuiteElement( XMLWriter ppw, WrappedReportEntry report, TestSetStats testSetStats,
349                                          String timeAsString )
350     {
351         ppw.startElement( "testsuite" );
352 
353         ppw.addAttribute( "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance" );
354         ppw.addAttribute( "xsi:noNamespaceSchemaLocation", xsdSchemaLocation );
355 
356         ppw.addAttribute( "name", report.getReportName( reportNameSuffix ) );
357 
358         if ( report.getGroup() != null )
359         {
360             ppw.addAttribute( "group", report.getGroup() );
361         }
362 
363         ppw.addAttribute( "time", timeAsString );
364 
365         ppw.addAttribute( "tests", String.valueOf( testSetStats.getCompletedCount() ) );
366 
367         ppw.addAttribute( "errors", String.valueOf( testSetStats.getErrors() ) );
368 
369         ppw.addAttribute( "skipped", String.valueOf( testSetStats.getSkipped() ) );
370 
371         ppw.addAttribute( "failures", String.valueOf( testSetStats.getFailures() ) );
372     }
373 
374     private static void getTestProblems( OutputStreamWriter outputStreamWriter, XMLWriter ppw,
375                                          WrappedReportEntry report, boolean trimStackTrace, OutputStream fw,
376                                          String testErrorType, boolean createOutErrElementsInside )
377     {
378         ppw.startElement( testErrorType );
379 
380         String stackTrace = report.getStackTrace( trimStackTrace );
381 
382         if ( report.getMessage() != null && !report.getMessage().isEmpty() )
383         {
384             ppw.addAttribute( "message", extraEscape( report.getMessage(), true ) );
385         }
386 
387         if ( report.getStackTraceWriter() != null )
388         {
389             //noinspection ThrowableResultOfMethodCallIgnored
390             SafeThrowable t = report.getStackTraceWriter().getThrowable();
391             if ( t != null )
392             {
393                 if ( t.getMessage() != null )
394                 {
395                     ppw.addAttribute( "type", ( stackTrace.contains( ":" )
396                         ? stackTrace.substring( 0, stackTrace.indexOf( ":" ) )
397                         : stackTrace ) );
398                 }
399                 else
400                 {
401                     ppw.addAttribute( "type", new StringTokenizer( stackTrace ).nextToken() );
402                 }
403             }
404         }
405 
406         if ( stackTrace != null )
407         {
408             ppw.writeText( extraEscape( stackTrace, false ) );
409         }
410 
411         if ( createOutErrElementsInside )
412         {
413             createOutErrElements( outputStreamWriter, ppw, report, fw );
414         }
415 
416         ppw.endElement(); // entry type
417     }
418 
419     // Create system-out and system-err elements
420     private static void createOutErrElements( OutputStreamWriter outputStreamWriter, XMLWriter ppw,
421                                               WrappedReportEntry report, OutputStream fw )
422     {
423         EncodingOutputStream eos = new EncodingOutputStream( fw );
424         addOutputStreamElement( outputStreamWriter, eos, ppw, report.getStdout(), "system-out" );
425         addOutputStreamElement( outputStreamWriter, eos, ppw, report.getStdErr(), "system-err" );
426     }
427 
428     private static void addOutputStreamElement( OutputStreamWriter outputStreamWriter,
429                                          EncodingOutputStream eos, XMLWriter xmlWriter,
430                                          Utf8RecodingDeferredFileOutputStream utf8RecodingDeferredFileOutputStream,
431                                          String name )
432     {
433         if ( utf8RecodingDeferredFileOutputStream != null && utf8RecodingDeferredFileOutputStream.getByteCount() > 0 )
434         {
435             xmlWriter.startElement( name );
436 
437             try
438             {
439                 xmlWriter.writeText( "" ); // Cheat sax to emit element
440                 outputStreamWriter.flush();
441                 utf8RecodingDeferredFileOutputStream.close();
442                 eos.getUnderlying().write( ByteConstantsHolder.CDATA_START_BYTES ); // emit cdata
443                 utf8RecodingDeferredFileOutputStream.writeTo( eos );
444                 eos.getUnderlying().write( ByteConstantsHolder.CDATA_END_BYTES );
445                 eos.flush();
446             }
447             catch ( IOException e )
448             {
449                 throw new ReporterException( "When writing xml report stdout/stderr", e );
450             }
451             xmlWriter.endElement();
452         }
453     }
454 
455     /**
456      * Adds system properties to the XML report.
457      * <br>
458      *
459      * @param xmlWriter The test suite to report to
460      */
461     private static void showProperties( XMLWriter xmlWriter, Map<String, String> systemProperties )
462     {
463         xmlWriter.startElement( "properties" );
464         for ( final Entry<String, String> entry : systemProperties.entrySet() )
465         {
466             final String key = entry.getKey();
467             String value = entry.getValue();
468 
469             if ( value == null )
470             {
471                 value = "null";
472             }
473 
474             xmlWriter.startElement( "property" );
475 
476             xmlWriter.addAttribute( "name", key );
477 
478             xmlWriter.addAttribute( "value", extraEscape( value, true ) );
479 
480             xmlWriter.endElement();
481         }
482         xmlWriter.endElement();
483     }
484 
485     /**
486      * Handle stuff that may pop up in java that is not legal in xml
487      *
488      * @param message   The string
489      * @param attribute true if the escaped value is inside an attribute
490      * @return The escaped string
491      */
492     private static String extraEscape( String message, boolean attribute )
493     {
494         // Someday convert to xml 1.1 which handles everything but 0 inside string
495         return containsEscapesIllegalXml10( message ) ? escapeXml( message, attribute ) : message;
496     }
497 
498     private static final class EncodingOutputStream
499         extends FilterOutputStream
500     {
501         private int c1;
502 
503         private int c2;
504 
505         public EncodingOutputStream( OutputStream out )
506         {
507             super( out );
508         }
509 
510         public OutputStream getUnderlying()
511         {
512             return out;
513         }
514 
515         private boolean isCdataEndBlock( int c )
516         {
517             return c1 == ']' && c2 == ']' && c == '>';
518         }
519 
520         @Override
521         public void write( int b )
522             throws IOException
523         {
524             if ( isCdataEndBlock( b ) )
525             {
526                 out.write( ByteConstantsHolder.CDATA_ESCAPE_STRING_BYTES );
527             }
528             else if ( isIllegalEscape( b ) )
529             {
530                 // uh-oh!  This character is illegal in XML 1.0!
531                 // http://www.w3.org/TR/1998/REC-xml-19980210#charsets
532                 // we're going to deliberately doubly-XML escape it...
533                 // there's nothing better we can do! :-(
534                 // SUREFIRE-456
535                 out.write( ByteConstantsHolder.AMP_BYTES );
536                 out.write( String.valueOf( b ).getBytes( UTF_8 ) );
537                 out.write( ';' ); // & Will be encoded to amp inside xml encodingSHO
538             }
539             else
540             {
541                 out.write( b );
542             }
543             c1 = c2;
544             c2 = b;
545         }
546     }
547 
548     private static boolean containsEscapesIllegalXml10( String message )
549     {
550         int size = message.length();
551         for ( int i = 0; i < size; i++ )
552         {
553             if ( isIllegalEscape( message.charAt( i ) ) )
554             {
555                 return true;
556             }
557 
558         }
559         return false;
560     }
561 
562     private static boolean isIllegalEscape( char c )
563     {
564         return isIllegalEscape( (int) c );
565     }
566 
567     private static boolean isIllegalEscape( int c )
568     {
569         return c >= 0 && c < 32 && c != '\n' && c != '\r' && c != '\t';
570     }
571 
572     private static String escapeXml( String text, boolean attribute )
573     {
574         StringBuilder sb = new StringBuilder( text.length() * 2 );
575         for ( int i = 0; i < text.length(); i++ )
576         {
577             char c = text.charAt( i );
578             if ( isIllegalEscape( c ) )
579             {
580                 // uh-oh!  This character is illegal in XML 1.0!
581                 // http://www.w3.org/TR/1998/REC-xml-19980210#charsets
582                 // we're going to deliberately doubly-XML escape it...
583                 // there's nothing better we can do! :-(
584                 // SUREFIRE-456
585                 sb.append( attribute ? "&#" : "&amp#" ).append( (int) c ).append(
586                     ';' ); // & Will be encoded to amp inside xml encodingSHO
587             }
588             else
589             {
590                 sb.append( c );
591             }
592         }
593         return sb.toString();
594     }
595 
596     private static final class ByteConstantsHolder
597     {
598         private static final byte[] CDATA_START_BYTES;
599 
600         private static final byte[] CDATA_END_BYTES;
601 
602         private static final byte[] CDATA_ESCAPE_STRING_BYTES;
603 
604         private static final byte[] AMP_BYTES;
605 
606         static
607         {
608             CDATA_START_BYTES = "<![CDATA[".getBytes( UTF_8 );
609             CDATA_END_BYTES = "]]>".getBytes( UTF_8 );
610             CDATA_ESCAPE_STRING_BYTES = "]]><![CDATA[>".getBytes( UTF_8 );
611             AMP_BYTES = "&amp#".getBytes( UTF_8 );
612         }
613     }
614 }