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