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"); +}