// Copyright 2014 The Bazel Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.devtools.build.lib.exec; import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableSet; import com.google.devtools.build.lib.view.test.TestStatus.TestCase; import com.google.devtools.build.lib.view.test.TestStatus.TestCase.Type; import com.google.protobuf.UninitializedMessageException; import java.io.InputStream; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; /** * Parses a test.xml generated by jUnit or any testing framework into a protocol buffer. The schema * of the test.xml is a bit hazy, so there is some guesswork involved. */ final class TestXmlOutputParser { // jUnit can use either "testsuites" or "testsuite". private static final ImmutableCollection<String> TOPLEVEL_ELEMENT_NAMES = ImmutableSet.of("testsuites", "testsuite"); public TestCase parseXmlIntoTestResult(InputStream xmlStream) throws TestXmlOutputParserException { return parseXmlToTree(xmlStream); } /** * Parses the a test result XML file into the corresponding protocol buffer. * * @param xmlStream the XML data stream * @return the protocol buffer with the parsed data, or null if there was an error while parsing * the file. * @throws TestXmlOutputParserException when the XML file cannot be parsed */ private TestCase parseXmlToTree(InputStream xmlStream) throws TestXmlOutputParserException { XMLStreamReader parser = null; try { parser = XMLInputFactory.newInstance().createXMLStreamReader(xmlStream); while (true) { int event = parser.next(); if (event == XMLStreamConstants.END_DOCUMENT) { return null; } // First find the topmost node. if (event == XMLStreamConstants.START_ELEMENT) { String elementName = parser.getLocalName(); if (TOPLEVEL_ELEMENT_NAMES.contains(elementName)) { TestCase result = parseTestSuite(parser, elementName); return result; } } } } catch (XMLStreamException e) { throw new TestXmlOutputParserException(e); } catch (NumberFormatException e) { // The parser is definitely != null here. throw new TestXmlOutputParserException( "Number could not be parsed at " + parser.getLocation().getLineNumber() + ":" + parser.getLocation().getColumnNumber(), e); } catch (UninitializedMessageException e) { // This happens when the XML does not contain a field that is required // in the protocol buffer throw new TestXmlOutputParserException(e); } catch (RuntimeException e) { // Seems like that an XNIException can leak through, even though it is not // specified anywhere. // // It's a bad idea to refer to XNIException directly because the Xerces // documentation says that it may not be available here soon (and it // results in a compile-time warning anyway), so we do it the roundabout // way: check if the class name has something to do with Xerces, and if // so, wrap it in our own exception type, otherwise, let the stack // unwinding continue. String name = e.getClass().getCanonicalName(); if (name != null && name.contains("org.apache.xerces")) { throw new TestXmlOutputParserException(e); } else { throw e; } } finally { if (parser != null) { try { parser.close(); } catch (XMLStreamException e) { // Ignore errors during closure so that we do not interfere with an // already propagating exception. } } } } /** * Creates an exception suitable to be thrown when and a bad end tag appears. The exception could * also be thrown from here but that would result in an extra stack frame, whereas this way, the * topmost frame shows the location where the error occurred. */ private TestXmlOutputParserException createBadElementException( String expected, XMLStreamReader parser) { return new TestXmlOutputParserException( "Expected end of XML element '" + expected + "' , but got '" + parser.getLocalName() + "' at " + parser.getLocation().getLineNumber() + ":" + parser.getLocation().getColumnNumber()); } /** * Parses a 'testsuite' element. * * @throws TestXmlOutputParserException if the XML document is malformed * @throws XMLStreamException if there was an error processing the XML * @throws NumberFormatException if one of the numeric fields does not contain a valid number */ private TestCase parseTestSuite(XMLStreamReader parser, String elementName) throws XMLStreamException, TestXmlOutputParserException { TestCase.Builder builder = TestCase.newBuilder(); builder.setType(Type.TEST_SUITE); for (int i = 0; i < parser.getAttributeCount(); i++) { String name = parser.getAttributeLocalName(i).intern(); String value = parser.getAttributeValue(i); if (name.equals("name")) { builder.setName(value); } else if (name.equals("time")) { builder.setRunDurationMillis(parseTime(value)); } } parseContainedElements(parser, elementName, builder); return builder.build(); } /** * Parses a time in test.xml format. * * @throws NumberFormatException if the time is malformed (i.e. is neither an integer nor a * decimal fraction with '.' as the fraction separator) */ private long parseTime(String string) { // This is ugly. For Historical Reasons, we have to check whether the number // contains a decimal point or not. If it does, the number is expressed in // milliseconds, otherwise, in seconds. if (string.contains(".")) { return Math.round(Float.parseFloat(string) * 1000); } else { return Long.parseLong(string); } } /** * Parses a 'decorator' element. * * @throws TestXmlOutputParserException if the XML document is malformed * @throws XMLStreamException if there was an error processing the XML * @throws NumberFormatException if one of the numeric fields does not contain a valid number */ private TestCase parseTestDecorator(XMLStreamReader parser) throws XMLStreamException, TestXmlOutputParserException { TestCase.Builder builder = TestCase.newBuilder(); builder.setType(Type.TEST_DECORATOR); for (int i = 0; i < parser.getAttributeCount(); i++) { String name = parser.getAttributeLocalName(i); String value = parser.getAttributeValue(i); builder.setName(name); if (name.equals("classname")) { builder.setClassName(value); } else if (name.equals("time")) { builder.setRunDurationMillis(parseTime(value)); } } parseContainedElements(parser, "testdecorator", builder); return builder.build(); } /** * Parses child elements of the specified tag. Strictly speaking, not every element can be a child * of every other, but the HierarchicalTestResult can handle that, and (in this case) it does not * hurt to be a bit more flexible than necessary. * * @throws TestXmlOutputParserException if the XML document is malformed * @throws XMLStreamException if there was an error processing the XML * @throws NumberFormatException if one of the numeric fields does not contain a valid number */ private void parseContainedElements( XMLStreamReader parser, String elementName, TestCase.Builder builder) throws XMLStreamException, TestXmlOutputParserException { int failures = 0; int errors = 0; while (true) { int event = parser.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: String childElementName = parser.getLocalName().intern(); // We are not parsing four elements here: system-out, system-err, // failure and error. They potentially contain useful information, but // they can be too big to fit in the memory. We add failure and error // elements to the output without a message, so that there is a // difference between passed and failed test cases. switch (childElementName) { case "testsuite": builder.addChild(parseTestSuite(parser, childElementName)); break; case "testcase": builder.addChild(parseTestCase(parser)); break; case "failure": failures += 1; skipCompleteElement(parser); break; case "error": errors += 1; skipCompleteElement(parser); break; case "testdecorator": builder.addChild(parseTestDecorator(parser)); break; default: // Unknown element encountered. Since the schema of the input file // is a bit hazy, just skip it and go merrily on our way. Ignorance // is bliss. skipCompleteElement(parser); } break; case XMLStreamConstants.END_ELEMENT: // Propagate errors/failures from children up to the current case for (int i = 0; i < builder.getChildCount(); i += 1) { if (builder.getChild(i).getStatus() == TestCase.Status.ERROR) { errors += 1; } if (builder.getChild(i).getStatus() == TestCase.Status.FAILED) { failures += 1; } } if (errors > 0) { builder.setStatus(TestCase.Status.ERROR); } else if (failures > 0) { builder.setStatus(TestCase.Status.FAILED); } else { builder.setStatus(TestCase.Status.PASSED); } // This is the end tag of the element we are supposed to parse. // Hooray, tell our superiors that our mission is complete. if (!parser.getLocalName().equals(elementName)) { throw createBadElementException(elementName, parser); } return; } } } /** * Parses a 'testcase' element. * * @throws TestXmlOutputParserException if the XML document is malformed * @throws XMLStreamException if there was an error processing the XML * @throws NumberFormatException if the time field does not contain a valid number */ private TestCase parseTestCase(XMLStreamReader parser) throws XMLStreamException, TestXmlOutputParserException { TestCase.Builder builder = TestCase.newBuilder(); builder.setType(Type.TEST_CASE); for (int i = 0; i < parser.getAttributeCount(); i++) { String name = parser.getAttributeLocalName(i).intern(); String value = parser.getAttributeValue(i); switch (name) { case "name": builder.setName(value); break; case "classname": builder.setClassName(value); break; case "time": builder.setRunDurationMillis(parseTime(value)); break; case "result": builder.setResult(value); break; case "status": if (value.equals("notrun")) { builder.setRun(false); } else if (value.equals("run")) { builder.setRun(true); } break; default: // fall through } } parseContainedElements(parser, "testcase", builder); return builder.build(); } /** * Skips over a complete XML element on the input. Precondition: the cursor is at a START_ELEMENT. * Postcondition: the cursor is at an END_ELEMENT. * * @throws XMLStreamException if the XML is malformed */ private void skipCompleteElement(XMLStreamReader parser) throws XMLStreamException { int depth = 1; while (true) { int event = parser.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: depth++; break; case XMLStreamConstants.END_ELEMENT: if (--depth == 0) { return; } break; } } } }