blob: fea69d26c29b22d2768a8ba597a724090d9b9a3d [file] [log] [blame]
// 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.protobuf.UninitializedMessageException;
import java.io.InputStream;
import javax.annotation.Nullable;
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.
*/
public 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 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
*/
@Nullable
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(TestCase.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(TestCase.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);
}
}
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;
}
default -> {}
}
}
}
/**
* 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(TestCase.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);
case "classname" -> builder.setClassName(value);
case "time" -> builder.setRunDurationMillis(parseTime(value));
case "result" -> builder.setResult(value);
case "status" -> {
if (value.equals("notrun")) {
builder.setRun(false);
} else if (value.equals("run")) {
builder.setRun(true);
}
}
default -> {}
}
}
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++;
case XMLStreamConstants.END_ELEMENT -> {
if (--depth == 0) {
return;
}
}
default -> {}
}
}
}
}