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.plugins.surefire.report;
20  
21  import javax.xml.parsers.ParserConfigurationException;
22  import javax.xml.parsers.SAXParser;
23  import javax.xml.parsers.SAXParserFactory;
24  
25  import java.io.File;
26  import java.io.IOException;
27  import java.io.InputStreamReader;
28  import java.nio.file.Files;
29  import java.util.ArrayList;
30  import java.util.HashMap;
31  import java.util.List;
32  import java.util.Map;
33  
34  import org.apache.maven.plugin.surefire.log.api.ConsoleLogger;
35  import org.xml.sax.Attributes;
36  import org.xml.sax.InputSource;
37  import org.xml.sax.SAXException;
38  import org.xml.sax.helpers.DefaultHandler;
39  
40  import static java.nio.charset.StandardCharsets.UTF_8;
41  import static org.apache.maven.shared.utils.StringUtils.isBlank;
42  
43  /**
44   *
45   */
46  public final class TestSuiteXmlParser extends DefaultHandler {
47      private final ConsoleLogger consoleLogger;
48  
49      private ReportTestSuite defaultSuite;
50  
51      private ReportTestSuite currentSuite;
52  
53      private Map<String, Integer> classesToSuitesIndex;
54  
55      private List<ReportTestSuite> suites;
56  
57      private StringBuilder currentElement;
58  
59      private ReportTestCase testCase;
60  
61      private ReportTestCase.FlakyFailure testCaseFlakyFailure;
62  
63      private ReportTestCase.FlakyError testCaseFlakyError;
64  
65      private boolean valid;
66  
67      private boolean parseContent;
68  
69      private long lastModified;
70  
71      public TestSuiteXmlParser(ConsoleLogger consoleLogger) {
72          this.consoleLogger = consoleLogger;
73      }
74  
75      public List<ReportTestSuite> parse(String xmlPath) throws ParserConfigurationException, SAXException, IOException {
76          File f = new File(xmlPath);
77          try (InputStreamReader stream = new InputStreamReader(Files.newInputStream(f.toPath()), UTF_8)) {
78              this.lastModified = f.lastModified();
79              return parse(stream);
80          }
81      }
82  
83      public List<ReportTestSuite> parse(InputStreamReader stream)
84              throws ParserConfigurationException, SAXException, IOException {
85          SAXParserFactory factory = SAXParserFactory.newInstance();
86  
87          SAXParser saxParser = factory.newSAXParser();
88  
89          valid = true;
90  
91          classesToSuitesIndex = new HashMap<>();
92          suites = new ArrayList<>();
93  
94          saxParser.parse(new InputSource(stream), this);
95  
96          if (currentSuite != defaultSuite) { // omit the defaultSuite if it's empty and there are alternatives
97              if (defaultSuite.getNumberOfTests() == 0) {
98                  suites.remove(classesToSuitesIndex
99                          .get(defaultSuite.getFullClassName())
100                         .intValue());
101             }
102         }
103 
104         return suites;
105     }
106 
107     /**
108      * {@inheritDoc}
109      */
110     @Override
111     public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
112         if (valid) {
113             try {
114                 switch (qName) {
115                     case "testsuite":
116                         defaultSuite = new ReportTestSuite();
117                         currentSuite = defaultSuite;
118                         String timeStr = attributes.getValue("time");
119                         if (timeStr != null) {
120                             defaultSuite.setTimeElapsed(Float.parseFloat(timeStr));
121                         } else {
122                             consoleLogger.warning("No time attribute found on testsuite element");
123                         }
124 
125                         final String name = attributes.getValue("name");
126                         final String group = attributes.getValue("group");
127                         defaultSuite.setFullClassName(
128                                 isBlank(group)
129                                         ? /*name is full class name*/ name
130                                         : /*group is package name*/ group + "." + name);
131 
132                         suites.add(defaultSuite);
133                         classesToSuitesIndex.put(defaultSuite.getFullClassName(), suites.size() - 1);
134                         break;
135                     case "testcase":
136                         // Although this element does not contain any text, this line must be retained because the
137                         // nested elements do have text content.
138                         currentElement = new StringBuilder();
139 
140                         testCase = new ReportTestCase().setName(attributes.getValue("name"));
141                         testCaseFlakyFailure = null;
142                         testCaseFlakyError = null;
143 
144                         String fullClassName = attributes.getValue("classname");
145 
146                         // if the testcase declares its own classname, it may need to belong to its own suite
147                         if (fullClassName != null) {
148                             Integer currentSuiteIndex = classesToSuitesIndex.get(fullClassName);
149                             if (currentSuiteIndex == null) {
150                                 currentSuite = new ReportTestSuite().setFullClassName(fullClassName);
151                                 suites.add(currentSuite);
152                                 classesToSuitesIndex.put(fullClassName, suites.size() - 1);
153                             } else {
154                                 currentSuite = suites.get(currentSuiteIndex);
155                             }
156                         }
157 
158                         timeStr = attributes.getValue("time");
159 
160                         testCase.setFullClassName(currentSuite.getFullClassName())
161                                 .setClassName(currentSuite.getName())
162                                 .setFullName(currentSuite.getFullClassName() + "." + testCase.getName())
163                                 .setTime(timeStr != null ? Float.parseFloat(timeStr) : 0.0f);
164 
165                         if (currentSuite != defaultSuite) {
166                             currentSuite.setTimeElapsed(testCase.getTime() + currentSuite.getTimeElapsed());
167                         }
168                         break;
169                     case "failure":
170                         currentElement = new StringBuilder();
171                         parseContent = true;
172 
173                         testCase.setFailure(attributes.getValue("message"), attributes.getValue("type"));
174                         currentSuite.incrementNumberOfFailures();
175                         break;
176                     case "error":
177                         currentElement = new StringBuilder();
178                         parseContent = true;
179 
180                         testCase.setError(attributes.getValue("message"), attributes.getValue("type"));
181                         currentSuite.incrementNumberOfErrors();
182                         break;
183                     case "skipped":
184                         String message = attributes.getValue("message");
185                         testCase.setSkipped(message != null ? message : "skipped");
186                         currentSuite.incrementNumberOfSkipped();
187                         break;
188                     case "flakyFailure":
189                         testCaseFlakyFailure = new ReportTestCase.FlakyFailure(
190                                 attributes.getValue("message"), attributes.getValue("type"));
191                         currentSuite.incrementNumberOfFlakes();
192                         break;
193                     case "flakyError":
194                         testCaseFlakyError = new ReportTestCase.FlakyError(
195                                 attributes.getValue("message"), attributes.getValue("type"));
196                         currentSuite.incrementNumberOfFlakes();
197                         break;
198                     case "stackTrace":
199                         currentElement = new StringBuilder();
200                         parseContent = true;
201                         break;
202                     case "failsafe-summary":
203                         valid = false;
204                         break;
205                     case "time":
206                         currentElement = new StringBuilder();
207                         parseContent = true;
208                         break;
209                     default:
210                         break;
211                 }
212             } catch (NumberFormatException e) {
213                 throw new SAXException("Failed to parse time value", e);
214             }
215         }
216     }
217 
218     /**
219      * {@inheritDoc}
220      */
221     @Override
222     public void endElement(String uri, String localName, String qName) throws SAXException {
223         switch (qName) {
224             case "testcase":
225                 currentSuite.getTestCases().add(testCase);
226                 currentSuite.lastModified(this.lastModified);
227                 break;
228             case "failure":
229             case "error":
230                 testCase.setFailureDetail(currentElement.toString())
231                         .setFailureErrorLine(parseErrorLine(currentElement, testCase.getFullClassName()));
232                 break;
233             case "time":
234                 try {
235                     defaultSuite.setTimeElapsed(Float.parseFloat(currentElement.toString()));
236                 } catch (NumberFormatException e) {
237                     throw new SAXException("Failed to parse time value", e);
238                 }
239                 break;
240             case "flakyFailure":
241                 testCase.addFlakyFailure(testCaseFlakyFailure);
242                 testCaseFlakyFailure = null;
243                 break;
244             case "flakyError":
245                 testCase.addFlakyError(testCaseFlakyError);
246                 testCaseFlakyError = null;
247                 break;
248             case "stackTrace":
249                 if (testCaseFlakyFailure != null) {
250                     testCaseFlakyFailure.setStackTrace(currentElement.toString());
251                 }
252                 if (testCaseFlakyError != null) {
253                     testCaseFlakyError.setStackTrace(currentElement.toString());
254                 }
255                 break;
256             default:
257                 break;
258         }
259         parseContent = false;
260         // TODO extract real skipped reasons
261     }
262 
263     /**
264      * {@inheritDoc}
265      */
266     @Override
267     public void characters(char[] ch, int start, int length) {
268         assert start >= 0;
269         assert length >= 0;
270         if (valid && parseContent && isNotBlank(start, length, ch)) {
271             currentElement.append(ch, start, length);
272         }
273     }
274 
275     public boolean isValid() {
276         return valid;
277     }
278 
279     static boolean isNotBlank(int from, int len, char... s) {
280         assert from >= 0;
281         assert len >= 0;
282         if (s != null) {
283             for (int i = 0; i < len; i++) {
284                 char c = s[from++];
285                 if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\f') {
286                     return true;
287                 }
288             }
289         }
290         return false;
291     }
292 
293     static boolean isNumeric(StringBuilder s, final int from, final int to) {
294         assert from >= 0;
295         assert from <= to;
296         for (int i = from; i != to; ) {
297             if (!Character.isDigit(s.charAt(i++))) {
298                 return false;
299             }
300         }
301         return from != to;
302     }
303 
304     static String parseErrorLine(StringBuilder currentElement, String fullClassName) {
305         final String[] linePatterns = {"at " + fullClassName + '.', "at " + fullClassName + '$'};
306         int[] indexes = lastIndexOf(currentElement, linePatterns);
307         int patternStartsAt = indexes[0];
308         if (patternStartsAt != -1) {
309             int searchFrom = patternStartsAt + (linePatterns[indexes[1]]).length();
310             searchFrom = 1 + currentElement.indexOf(":", searchFrom);
311             int searchTo = currentElement.indexOf(")", searchFrom);
312             return isNumeric(currentElement, searchFrom, searchTo)
313                     ? currentElement.substring(searchFrom, searchTo)
314                     : "";
315         }
316         return "";
317     }
318 
319     static int[] lastIndexOf(StringBuilder source, String... linePatterns) {
320         int end = source.indexOf("Caused by:");
321         if (end == -1) {
322             end = source.length();
323         }
324         int startsAt = -1;
325         int pattern = -1;
326         for (int i = 0; i < linePatterns.length; i++) {
327             String linePattern = linePatterns[i];
328             int currentStartsAt = source.lastIndexOf(linePattern, end);
329             if (currentStartsAt > startsAt) {
330                 startsAt = currentStartsAt;
331                 pattern = i;
332             }
333         }
334         return new int[] {startsAt, pattern};
335     }
336 }