/*
* Copyright (c) 2011-2013, SOASTA, Inc.
* All Rights Reserved.
*/
package com.soasta.jenkins;
import hudson.Launcher;
import hudson.Extension;
import hudson.model.AbstractBuild;
import hudson.model.BuildListener;
import hudson.model.Descriptor;
import hudson.tasks.junit.CaseResult;
import hudson.tasks.junit.SuiteResult;
import hudson.tasks.junit.TestAction;
import hudson.tasks.junit.TestDataPublisher;
import hudson.tasks.junit.TestObject;
import hudson.tasks.junit.TestResult;
import hudson.tasks.junit.TestResultAction;
import hudson.util.FormValidation;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.w3c.dom.Node;
import org.xml.sax.InputSource;
import java.io.IOException;
import java.io.StringReader;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
@SuppressWarnings("deprecation")
public class JunitResultPublisher extends TestDataPublisher
{
private static final String MESSAGE_PATH = "path";
private static final String MESSAGE_CLIP_NAME = "clipName";
private static final String MESSAGE_CLIP_TYPE = "type";
private String urlOverride;
/**
* Called by Jenkins when the job is initialized.
* @param the URL override setting from the job configuration (can be {@code null}).
*/
@DataBoundConstructor
public JunitResultPublisher(String urlOverride)
{
this.urlOverride = urlOverride;
}
/**
* Called by Jenkins when rendering the job configuration page.
* @return the current URL override (if any).
*/
public String getUrlOverride()
{
return urlOverride;
}
@Override
public TestResultAction.Data getTestData(AbstractBuild<?, ?> build,
Launcher launcher, BuildListener listener, TestResult testResult) throws IOException
{
Data data = new Data();
for (SuiteResult sr : testResult.getSuites())
{
JunitResultAction action = new JunitResultAction();
// Get the local path of the JUnit XML file (may be on a slave node).
String fileName = sr.getFile();
// Get local path of the build's workspace.
String workspacePath = build.getWorkspace().getRemote();
// Check if there is a file to parse through
if (fileName == null || fileName.isEmpty())
{
listener.error("The selected JUnit XML file " + sr.getName() + " has no content in it. Skipping.");
continue;
}
// Is the JUnit XML file in the workspace?
if (fileName.startsWith(workspacePath))
{
// The JUnit XML file is in the workspace.
try
{
// Load the JUnit XML file contents. If the build is
// on a slave, then this will load over the network.
String relativePath = fileName.substring(workspacePath.length() + 1);
String fileContent = build.getWorkspace().child(relativePath).readToString();
Document junitXML = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(fileContent)));
XPath xPath = XPathFactory.newInstance().newXPath();
// Extract the CloudTest result ID (if any) from the JUnit XML file.
String resultID = (String)xPath.evaluate("//testcase[1]/@resultID", junitXML, XPathConstants.STRING);
// Did we find a result ID?
if (resultID != null && resultID.trim().length() > 0)
{
// We found a result ID.
String url;
// Is the CloudTest URL specified at the job level?
if (this.urlOverride != null && this.urlOverride.trim().length() > 0)
{
// The CloudTest URL is specified at the job level.
// Use that.
url = this.urlOverride;
}
else
{
// The CloudTest URL is not specified at the job level (normal case).
// Extract it from the JUnit XML.
url = (String)xPath.evaluate("//testsuite/@url", junitXML, XPathConstants.STRING);
}
// Extract the detailed error messages, if any.
NodeList messageNodes = (NodeList)xPath.evaluate("//testcase[1]/messages/message", junitXML, XPathConstants.NODESET);
List<Message> messages = new ArrayList<Message>();
if (messageNodes != null)
{
Message resultsMessage = null; // The message object that will be created from the strings: type and message.
for (int i = 0; i < messageNodes.getLength(); i++)
{
Node messageNode = messageNodes.item(i);
// Checks to see if there are type and path attributes in this message.
// This check ensures the code is backwards compatible with older
// versions of CloudTest, where the result messages contained neither type
// nor path attributes.
String type = getNodeTextContent(messageNode, MESSAGE_CLIP_TYPE); // The type of message (i.e. "validation-pass").
String path = getNodeTextContent(messageNode, MESSAGE_PATH); // The full path of the clip.
if (path == null)
{
// Get the name of the clip and use the clip name if clip path is not
// available. This will also ensure backwards compatibility when a
// message's clip name was passed but the message's path was not.
path = getNodeTextContent(messageNode, MESSAGE_CLIP_NAME);
}
String message = messageNode.getTextContent(); // The result message itself.
resultsMessage = new Message(type, path, message);
// Add it to the list. It is assumed that the messages being parsed
// are already in chronological order.
messages.add(resultsMessage);
}
}
if (resultID.equals("NA"))
action.setPlayList(true);
// Store the result ID and URL in the Action object.
// This will be used later on to render the test report.
action.setResultID(resultID);
action.setUrl(url);
action.setMessages(messages);
}
data.addTestAction(sr.getCases().get(0).getId(), action);
}
catch (Exception e)
{
listener.error("File \"" + fileName + "\" could not be processed (" + e.getMessage() + "). Skipping.");
}
}
else
{
listener.error("File \"" + fileName + "\" does not appear to be in build workspace. Skipping.");
}
}
return data;
}
private String getNodeTextContent(Node node, String namedItem)
{
if (node == null || !node.hasAttributes())
{
return null;
}
Node nodeValue = node.getAttributes().getNamedItem(namedItem);
if (nodeValue == null)
{
return null;
}
return nodeValue.getTextContent();
}
@Override
public DescriptorImpl getDescriptor()
{
return (DescriptorImpl)super.getDescriptor();
}
private static class Data extends TestResultAction.Data
{
private Map<String,JunitResultAction> actions = new HashMap<String,JunitResultAction>();
@Override
public List<TestAction> getTestAction(TestObject testObject)
{
if (testObject instanceof CaseResult)
{
String id = testObject.getId();
JunitResultAction action = actions.get(id);
if (action != null)
{
return Collections.<TestAction>singletonList(action);
}
}
return Collections.emptyList();
}
public void addTestAction(String testObjectId, JunitResultAction action)
{
actions.put(testObjectId, action);
}
}
@Extension
public static class DescriptorImpl extends Descriptor<TestDataPublisher>
{
/**
* Called automatically by Jenkins when rendering the job configuration page.
*/
@Override
public String getDisplayName()
{
return "Include links to SOASTA CloudTest dashboards";
}
/**
* Called automatically by Jenkins whenever the "urlOverride"
* field is modified by the user.
* @param value the new URL.
*/
public FormValidation doCheckUrlOverride(@QueryParameter String value)
{
// Did the user enter a URL?
if (value == null || value.trim().length() == 0)
{
// The user did not enter a URL.
// This is always valid (and the usual case).
return FormValidation.ok();
}
else
{
// The user entered a URL.
// Make sure it's valid.
try
{
// Attempt to parse the URL.
new URL(value);
// Success!
return FormValidation.ok();
}
catch (Exception e)
{
// Failed to parse URL.
return FormValidation.error("Invalid URL");
}
}
}
}
}