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