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