diff --git a/core/src/main/java/hudson/Platform.java b/core/src/main/java/hudson/Platform.java
index ccd1f2f..5408ea7 100644
--- a/core/src/main/java/hudson/Platform.java
+++ b/core/src/main/java/hudson/Platform.java
@@ -55,6 +55,10 @@
if(File.pathSeparatorChar==':') return UNIX;
return WINDOWS;
}
+
+ public static boolean isWindows() {
+ return current().equals(WINDOWS);
+ }
public static boolean isDarwin() {
// according to http://developer.apple.com/technotes/tn2002/tn2110.html
diff --git a/core/src/main/java/hudson/tasks/junit/SuiteResult.java b/core/src/main/java/hudson/tasks/junit/SuiteResult.java
new file mode 100644
index 0000000..cf0041f
--- /dev/null
+++ b/core/src/main/java/hudson/tasks/junit/SuiteResult.java
@@ -0,0 +1,360 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi, Erik Ramfelt, Xavier Le Vourch, Tom Huybrechts, Yahoo!, Inc., Victor Garcia
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package hudson.tasks.junit;
+
+import hudson.Platform;
+import hudson.tasks.test.TestObject;
+import hudson.util.IOException2;
+import hudson.util.io.ParserConfigurator;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.io.Serializable;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.FileUtils;
+import org.dom4j.Document;
+import org.dom4j.DocumentException;
+import org.dom4j.Element;
+import org.dom4j.io.SAXReader;
+import org.kohsuke.stapler.export.Exported;
+import org.kohsuke.stapler.export.ExportedBean;
+
+/**
+ * Result of one test suite.
+ *
+ *
+ * The notion of "test suite" is rather arbitrary in JUnit ant task.
+ * It's basically one invocation of junit.
+ *
+ *
+ * This object is really only used as a part of the persisted
+ * object tree.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+@ExportedBean
+public final class SuiteResult implements Serializable {
+ private final String file;
+ private final String name;
+ private final String stdout;
+ private final String stderr;
+ private float duration;
+ /**
+ * The 'timestamp' attribute of the test suite.
+ * AFAICT, this is not a required attribute in XML, so the value may be null.
+ */
+ private String timestamp;
+ /** Optional ID attribute of a test suite. E.g., Eclipse plug-ins tests always have the name 'tests' but a different id. **/
+ private String id;
+
+ /**
+ * All test cases.
+ */
+ private final List cases = new ArrayList();
+ private transient Map casesByName;
+ private transient hudson.tasks.junit.TestResult parent;
+
+ SuiteResult(String name, String stdout, String stderr) {
+ this.name = name;
+ this.stderr = stderr;
+ this.stdout = stdout;
+ this.file = null;
+ }
+
+ private synchronized Map casesByName() {
+ if (casesByName == null) {
+ casesByName = new HashMap();
+ for (CaseResult c : cases) {
+ casesByName.put(c.getName(), c);
+ }
+ }
+ return casesByName;
+ }
+
+ /**
+ * Passed to {@link ParserConfigurator}.
+ * @since 1.416
+ */
+ public static class SuiteResultParserConfigurationContext {
+ public final File xmlReport;
+
+ SuiteResultParserConfigurationContext(File xmlReport) {
+ this.xmlReport = xmlReport;
+ }
+ }
+
+ /**
+ * Parses the JUnit XML file into {@link SuiteResult}s.
+ * This method returns a collection, as a single XML may have multiple <testsuite>
+ * elements wrapped into the top-level <testsuites>.
+ */
+ static List parse(File xmlReport, boolean keepLongStdio) throws DocumentException, IOException, InterruptedException {
+ List r = new ArrayList();
+
+ // parse into DOM
+ SAXReader saxReader = new SAXReader();
+ ParserConfigurator.applyConfiguration(saxReader,new SuiteResultParserConfigurationContext(xmlReport));
+
+ Document result = saxReader.read(xmlReport);
+ Element root = result.getRootElement();
+
+ parseSuite(xmlReport,keepLongStdio,r,root);
+
+ return r;
+ }
+
+ private static void parseSuite(File xmlReport, boolean keepLongStdio, List r, Element root) throws DocumentException, IOException {
+ // nested test suites
+ @SuppressWarnings("unchecked")
+ List testSuites = (List)root.elements("testsuite");
+ for (Element suite : testSuites)
+ parseSuite(xmlReport, keepLongStdio, r, suite);
+
+ // child test cases
+ // FIXME: do this also if no testcases!
+ if (root.element("testcase")!=null || root.element("error")!=null)
+ r.add(new SuiteResult(xmlReport, root, keepLongStdio));
+ }
+
+ /**
+ * @param xmlReport
+ * A JUnit XML report file whose top level element is 'testsuite'.
+ * @param suite
+ * The parsed result of {@code xmlReport}
+ */
+ private SuiteResult(File xmlReport, Element suite, boolean keepLongStdio) throws DocumentException, IOException {
+ this.file = xmlReport.getAbsolutePath();
+ String name = suite.attributeValue("name");
+ if(name==null)
+ // some user reported that name is null in their environment.
+ // see http://www.nabble.com/Unexpected-Null-Pointer-Exception-in-Hudson-1.131-tf4314802.html
+ name = '('+xmlReport.getName()+')';
+ else {
+ String pkg = suite.attributeValue("package");
+ if(pkg!=null&& pkg.length()>0) name=pkg+'.'+name;
+ }
+ this.name = TestObject.safe(name);
+ this.timestamp = suite.attributeValue("timestamp");
+ this.id = suite.attributeValue("id");
+
+ Element ex = suite.element("error");
+ if(ex!=null) {
+ // according to junit-noframes.xsl l.229, this happens when the test class failed to load
+ addCase(new CaseResult(this, suite, "", keepLongStdio));
+ }
+
+ @SuppressWarnings("unchecked")
+ List testCases = (List)suite.elements("testcase");
+ for (Element e : testCases) {
+ // https://issues.jenkins-ci.org/browse/JENKINS-1233 indicates that
+ // when is present, we are better off using @classname on the
+ // individual testcase class.
+
+ // https://issues.jenkins-ci.org/browse/JENKINS-1463 indicates that
+ // @classname may not exist in individual testcase elements. We now
+ // also test if the testsuite element has a package name that can be used
+ // as the class name instead of the file name which is default.
+ String classname = e.attributeValue("classname");
+ if (classname == null) {
+ classname = suite.attributeValue("name");
+ }
+
+ // https://issues.jenkins-ci.org/browse/JENKINS-1233 and
+ // http://www.nabble.com/difference-in-junit-publisher-and-ant-junitreport-tf4308604.html#a12265700
+ // are at odds with each other --- when both are present,
+ // one wants to use @name from ,
+ // the other wants to use @classname from .
+
+ addCase(new CaseResult(this, e, classname, keepLongStdio));
+ }
+
+ String stdout = CaseResult.possiblyTrimStdio(cases, keepLongStdio, suite.elementText("system-out"));
+ String stderr = CaseResult.possiblyTrimStdio(cases, keepLongStdio, suite.elementText("system-err"));
+ if (stdout==null && stderr==null) {
+ // Surefire never puts stdout/stderr in the XML. Instead, it goes to a separate file (when ${maven.test.redirectTestOutputToFile}).
+ Matcher m = SUREFIRE_FILENAME.matcher(xmlReport.getName());
+ if (m.matches()) {
+ // look for ***-output.txt from TEST-***.xml
+ File mavenOutputFile = new File(xmlReport.getParentFile(),m.group(1)+"-output.txt");
+ if (mavenOutputFile.exists()) {
+ stdout = readStdoutFromFile(keepLongStdio, mavenOutputFile);
+ }
+ }
+ }
+
+ this.stdout = stdout;
+ this.stderr = stderr;
+ }
+
+ private String readStdoutFromFile(boolean keepLongStdio, File mavenOutputFile) throws IOException2 {
+ final String result;
+ try {
+ if (!Platform.isWindows()) {
+ RandomAccessFile raf = new RandomAccessFile(mavenOutputFile, "r");
+ try {
+ ByteBuffer bb = raf.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, mavenOutputFile.length());
+ CharBuffer cb = Charset.defaultCharset().decode(bb);
+ result = CaseResult.possiblyTrimStdio(cases, keepLongStdio, cb);
+ } finally {
+ raf.close();
+ }
+ } else {
+ // "[...] Windows does not allow a mapped file to be deleted. [...]"
+ // see http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4715154
+ result = CaseResult.possiblyTrimStdio(cases, keepLongStdio, FileUtils.readFileToString(mavenOutputFile));
+ }
+ } catch (IOException e) {
+ throw new IOException2("Failed to read " + mavenOutputFile, e);
+ }
+ return result;
+ }
+
+ /*package*/ void addCase(CaseResult cr) {
+ cases.add(cr);
+ casesByName().put(cr.getName(), cr);
+ duration += cr.getDuration();
+ }
+
+ @Exported(visibility=9)
+ public String getName() {
+ return name;
+ }
+
+ @Exported(visibility=9)
+ public float getDuration() {
+ return duration;
+ }
+
+ /**
+ * The stdout of this test.
+ *
+ * @since 1.281
+ * @see CaseResult#getStdout()
+ */
+ @Exported
+ public String getStdout() {
+ return stdout;
+ }
+
+ /**
+ * The stderr of this test.
+ *
+ * @since 1.281
+ * @see CaseResult#getStderr()
+ */
+ @Exported
+ public String getStderr() {
+ return stderr;
+ }
+
+ /**
+ * The absolute path to the original test report. OS-dependent.
+ */
+ public String getFile() {
+ return file;
+ }
+
+ public hudson.tasks.junit.TestResult getParent() {
+ return parent;
+ }
+
+ @Exported(visibility=9)
+ public String getTimestamp() {
+ return timestamp;
+ }
+
+ @Exported(visibility=9)
+ public String getId() {
+ return id;
+ }
+
+ @Exported(inline=true,visibility=9)
+ public List getCases() {
+ return cases;
+ }
+
+ public SuiteResult getPreviousResult() {
+ hudson.tasks.test.TestResult pr = parent.getPreviousResult();
+ if(pr==null) return null;
+ if(pr instanceof hudson.tasks.junit.TestResult)
+ return ((hudson.tasks.junit.TestResult)pr).getSuite(name);
+ return null;
+ }
+
+ /**
+ * Returns the {@link CaseResult} whose {@link CaseResult#getName()}
+ * is the same as the given string.
+ *
+ *
+ * Note that test name needs not be unique.
+ */
+ public CaseResult getCase(String name) {
+ return casesByName().get(name);
+ }
+
+ public Set getClassNames() {
+ Set result = new HashSet();
+ for (CaseResult c : cases) {
+ result.add(c.getClassName());
+ }
+ return result;
+ }
+
+ /** KLUGE. We have to call this to prevent freeze()
+ * from calling c.freeze() on all its children,
+ * because that in turn calls c.getOwner(),
+ * which requires a non-null parent.
+ * @param parent
+ */
+ void setParent(hudson.tasks.junit.TestResult parent) {
+ this.parent = parent;
+ }
+
+ /*package*/ boolean freeze(hudson.tasks.junit.TestResult owner) {
+ if(this.parent!=null)
+ return false; // already frozen
+
+ this.parent = owner;
+ for (CaseResult c : cases)
+ c.freeze(this);
+ return true;
+ }
+
+ private static final long serialVersionUID = 1L;
+
+ private static final Pattern SUREFIRE_FILENAME = Pattern.compile("TEST-(.+)\\.xml");
+}