/* * Copyright 2019 Qameta Software OÜ * * 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 io.qameta.allure.junitxml; import io.qameta.allure.Reader; import io.qameta.allure.context.RandomUidContext; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.ResultsVisitor; import io.qameta.allure.datetime.CompositeDateTimeParser; import io.qameta.allure.datetime.DateTimeParser; import io.qameta.allure.datetime.LocalDateTimeParser; import io.qameta.allure.datetime.ZonedDateTimeParser; import io.qameta.allure.entity.LabelName; import io.qameta.allure.entity.StageResult; import io.qameta.allure.entity.Status; import io.qameta.allure.entity.Step; import io.qameta.allure.entity.TestResult; import io.qameta.allure.entity.Time; import io.qameta.allure.parser.XmlElement; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import java.io.IOException; import java.math.BigDecimal; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Arrays; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import static io.qameta.allure.entity.LabelName.RESULT_FORMAT; import static java.nio.file.Files.newDirectoryStream; import static java.util.Collections.singletonList; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; /** * Plugin that reads data in JUnit.xml format. * * @since 2.0 */ @SuppressWarnings({"PMD.ExcessiveImports", "ClassDataAbstractionCoupling", "ClassFanOutComplexity"}) public class JunitXmlPlugin implements Reader { private static final Logger LOGGER = LoggerFactory.getLogger(JunitXmlPlugin.class); public static final String JUNIT_RESULTS_FORMAT = "junit"; private static final BigDecimal MULTIPLICAND = new BigDecimal(1000); private static final String TEST_SUITE_ELEMENT_NAME = "testsuite"; private static final String TEST_SUITES_ELEMENT_NAME = "testsuites"; private static final String TEST_CASE_ELEMENT_NAME = "testcase"; private static final String CLASS_NAME_ATTRIBUTE_NAME = "classname"; private static final String NAME_ATTRIBUTE_NAME = "name"; private static final String TIME_ATTRIBUTE_NAME = "time"; private static final String FAILURE_ELEMENT_NAME = "failure"; private static final String ERROR_ELEMENT_NAME = "error"; private static final String SKIPPED_ELEMENT_NAME = "skipped"; private static final String MESSAGE_ATTRIBUTE_NAME = "message"; private static final String RERUN_FAILURE_ELEMENT_NAME = "rerunFailure"; private static final String RERUN_ERROR_ELEMENT_NAME = "rerunError"; private static final String HOSTNAME_ATTRIBUTE_NAME = "hostname"; private static final String TIMESTAMP_ATTRIBUTE_NAME = "timestamp"; private static final String STATUS_ATTRIBUTE_NAME = "status"; private static final String SKIPPED_ATTRIBUTE_VALUE = "notrun"; private static final String SYSTEM_OUTPUT_ELEMENT_NAME = "system-out"; private static final String XML_GLOB = "*.xml"; private static final Map<String, Status> RETRIES; static { RETRIES = new HashMap<>(); RETRIES.put(RERUN_FAILURE_ELEMENT_NAME, Status.FAILED); RETRIES.put(RERUN_ERROR_ELEMENT_NAME, Status.BROKEN); } private final DateTimeParser parser; public JunitXmlPlugin() { this(ZoneOffset.systemDefault()); } public JunitXmlPlugin(final ZoneId defaultZoneId) { parser = new CompositeDateTimeParser( new ZonedDateTimeParser(), new LocalDateTimeParser(defaultZoneId) ); } @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") @Override public void readResults(final Configuration configuration, final ResultsVisitor visitor, final Path directory) { final RandomUidContext context = configuration.requireContext(RandomUidContext.class); listResults(directory).forEach(result -> parseRootElement(directory, result, context, visitor)); } private void parseRootElement(final Path resultsDirectory, final Path parsedFile, final RandomUidContext context, final ResultsVisitor visitor) { try { LOGGER.debug("Parsing file {}", parsedFile); final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); final DocumentBuilder builder = factory.newDocumentBuilder(); final XmlElement rootElement = new XmlElement(builder.parse(parsedFile.toFile()).getDocumentElement()); final String elementName = rootElement.getName(); if (TEST_SUITE_ELEMENT_NAME.equals(elementName)) { parseTestSuite(rootElement, parsedFile, context, visitor, resultsDirectory); return; } if (TEST_SUITES_ELEMENT_NAME.equals(elementName)) { rootElement.get(TEST_SUITE_ELEMENT_NAME) .forEach(element -> parseTestSuite(element, parsedFile, context, visitor, resultsDirectory)); return; } LOGGER.debug("File {} is not a valid JUnit xml. Unknown root element {}", parsedFile, elementName); } catch (SAXException | ParserConfigurationException | IOException e) { LOGGER.error("Could not parse file {}: {}", parsedFile, e); } } private void parseTestSuite(final XmlElement testSuiteElement, final Path parsedFile, final RandomUidContext context, final ResultsVisitor visitor, final Path resultsDirectory) { final String name = testSuiteElement.getAttribute(NAME_ATTRIBUTE_NAME); final String hostname = testSuiteElement.getAttribute(HOSTNAME_ATTRIBUTE_NAME); final String timestamp = testSuiteElement.getAttribute(TIMESTAMP_ATTRIBUTE_NAME); final TestSuiteInfo info = new TestSuiteInfo() .setName(name) .setHostname(hostname) .setTimestamp(getUnix(timestamp)); testSuiteElement.get(TEST_CASE_ELEMENT_NAME) .forEach(element -> parseTestCase(info, element, resultsDirectory, parsedFile, context, visitor)); } private Long getUnix(final String timestamp) { if (isNull(timestamp)) { return null; } return parser.getEpochMilli(timestamp) .orElse(null); } private void parseTestCase(final TestSuiteInfo info, final XmlElement testCaseElement, final Path resultsDirectory, final Path parsedFile, final RandomUidContext context, final ResultsVisitor visitor) { final String className = testCaseElement.getAttribute(CLASS_NAME_ATTRIBUTE_NAME); final Status status = getStatus(testCaseElement); final TestResult result = createStatuslessTestResult(info, testCaseElement, parsedFile, context); result.setStatus(status); result.setFlaky(isFlaky(testCaseElement)); setStatusDetails(result, testCaseElement); final StageResult stageResult = new StageResult(); getLogMessage(testCaseElement).ifPresent(logMessage -> { final List<String> lines = splitLines(logMessage); final List<Step> steps = lines .stream() .map(line -> new Step().setName(line)) .collect(Collectors.toList()); stageResult.setSteps(steps); }); getLogFile(resultsDirectory, className) .filter(Files::exists) .map(visitor::visitAttachmentFile) .map(attachment1 -> attachment1.setName("System out")) .ifPresent(attachment -> stageResult.setAttachments(singletonList(attachment))); result.setTestStage(stageResult); visitor.visitTestResult(result); RETRIES.forEach((elementName, retryStatus) -> testCaseElement.get(elementName).forEach(failure -> { final TestResult retried = createStatuslessTestResult(info, testCaseElement, parsedFile, context); retried.setHidden(true); retried.setStatus(retryStatus); retried.setStatusMessage(failure.getAttribute(MESSAGE_ATTRIBUTE_NAME)); retried.setStatusTrace(failure.getValue()); visitor.visitTestResult(retried); })); } private List<String> splitLines(final String str) { return Arrays.asList(str.split("\\r?\\n")); } private Optional<String> getLogMessage(final XmlElement testCaseElement) { return testCaseElement.getFirst(SYSTEM_OUTPUT_ELEMENT_NAME).map(XmlElement::getValue); } private Optional<Path> getLogFile(final Path resultsDirectory, final String className) { try { return Optional.ofNullable(className) .map(name -> name + ".txt") .map(resultsDirectory::resolve); } catch (InvalidPathException e) { LOGGER.debug("Can not find log file: invalid className {}", className, e); return Optional.empty(); } } private TestResult createStatuslessTestResult(final TestSuiteInfo info, final XmlElement testCaseElement, final Path parsedFile, final RandomUidContext context) { final String className = testCaseElement.getAttribute(CLASS_NAME_ATTRIBUTE_NAME); final Optional<String> suiteName = firstNotNull(info.getName(), className); final String name = testCaseElement.getAttribute(NAME_ATTRIBUTE_NAME); final String historyId = String.format("%s:%s#%s", info.getName(), className, name); final TestResult result = new TestResult(); if (nonNull(className) && nonNull(name)) { result.setHistoryId(historyId); } result.setUid(context.getValue().get()); result.setName(isNull(name) ? "Unknown test case" : name); result.setTime(getTime(info.getTimestamp(), testCaseElement, parsedFile)); result.addLabelIfNotExists(RESULT_FORMAT, JUNIT_RESULTS_FORMAT); suiteName.ifPresent(s -> result.addLabelIfNotExists(LabelName.SUITE, s)); if (nonNull(info.getHostname())) { result.addLabelIfNotExists(LabelName.HOST, info.getHostname()); } if (nonNull(className)) { result.addLabelIfNotExists(LabelName.TEST_CLASS, className); result.addLabelIfNotExists(LabelName.PACKAGE, className); } return result; } private Status getStatus(final XmlElement testCaseElement) { if (testCaseElement.contains(FAILURE_ELEMENT_NAME)) { return Status.FAILED; } if (testCaseElement.contains(ERROR_ELEMENT_NAME)) { return Status.BROKEN; } if (testCaseElement.contains(SKIPPED_ELEMENT_NAME)) { return Status.SKIPPED; } if ((testCaseElement.containsAttribute(STATUS_ATTRIBUTE_NAME)) && (testCaseElement.getAttribute(STATUS_ATTRIBUTE_NAME).equals(SKIPPED_ATTRIBUTE_VALUE))) { return Status.SKIPPED; } return Status.PASSED; } private void setStatusDetails(final TestResult result, final XmlElement testCaseElement) { Stream.of(FAILURE_ELEMENT_NAME, ERROR_ELEMENT_NAME, SKIPPED_ELEMENT_NAME) .filter(testCaseElement::contains) .map(testCaseElement::get) .filter(elements -> !elements.isEmpty()) .flatMap(Collection::stream) .findFirst() .ifPresent(element -> { //@formatter:off result.setStatusMessage(element.getAttribute(MESSAGE_ATTRIBUTE_NAME)); result.setStatusTrace(element.getValue()); //@formatter:on }); } private Time getTime(final Long suiteStart, final XmlElement testCaseElement, final Path parsedFile) { if (testCaseElement.containsAttribute(TIME_ATTRIBUTE_NAME)) { try { final long duration = BigDecimal.valueOf(testCaseElement.getDoubleAttribute(TIME_ATTRIBUTE_NAME)) .multiply(MULTIPLICAND) .longValue(); return nonNull(suiteStart) ? new Time().setStart(suiteStart).setStop(suiteStart + duration).setDuration(duration) : new Time().setDuration(duration); } catch (Exception e) { LOGGER.debug( "Could not parse time attribute for element {} in file {}", testCaseElement, parsedFile, e ); } } return new Time(); } private boolean isFlaky(final XmlElement testCaseElement) { return testCaseElement.contains(RERUN_ERROR_ELEMENT_NAME) || testCaseElement.contains(RERUN_FAILURE_ELEMENT_NAME); } private static List<Path> listResults(final Path directory) { final List<Path> result = new ArrayList<>(); if (!Files.isDirectory(directory)) { return result; } try (DirectoryStream<Path> directoryStream = newDirectoryStream(directory, XML_GLOB)) { for (Path path : directoryStream) { if (!Files.isDirectory(path)) { result.add(path); } } } catch (IOException e) { LOGGER.error("Could not read data from {}: {}", directory, e); } return result; } private static Optional<String> firstNotNull(final String... values) { return Stream.of(values) .filter(Objects::nonNull) .findFirst(); } }