/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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 org.apache.tools.ant.taskdefs.optional.junit; import java.io.BufferedOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.nio.file.Files; import java.util.Vector; import java.util.stream.Stream; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.apache.tools.ant.BuildException; import org.apache.tools.ant.DirectoryScanner; import org.apache.tools.ant.Project; import org.apache.tools.ant.Task; import org.apache.tools.ant.types.FileSet; import org.apache.tools.ant.util.DOMElementWriter; import org.apache.tools.ant.util.FileUtils; import org.apache.tools.ant.util.StringUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.SAXException; /** * Aggregates all <junit> XML formatter testsuite data under * a specific directory and transforms the results via XSLT. * It is not particularly clean but * should be helpful while I am thinking about another technique. * * <p> The main problem is due to the fact that a JVM can be forked for a testcase * thus making it impossible to aggregate all testcases since the listener is * (obviously) in the forked JVM. A solution could be to write a * TestListener that will receive events from the TestRunner via sockets. This * is IMHO the simplest way to do it to avoid this file hacking thing. * * @ant.task name="junitreport" category="testing" */ public class XMLResultAggregator extends Task implements XMLConstants { // CheckStyle:VisibilityModifier OFF - bc /** the list of all filesets, that should contains the xml to aggregate */ protected Vector<FileSet> filesets = new Vector<>(); /** the name of the result file */ protected String toFile; /** the directory to write the file to */ protected File toDir; protected Vector<AggregateTransformer> transformers = new Vector<>(); /** The default directory: <tt>.</tt>. It is resolved from the project directory */ public static final String DEFAULT_DIR = "."; /** the default file name: <tt>TESTS-TestSuites.xml</tt> */ public static final String DEFAULT_FILENAME = "TESTS-TestSuites.xml"; /** the current generated id */ protected int generatedId = 0; /** * text checked for in tests, {@value} */ static final String WARNING_IS_POSSIBLY_CORRUPTED = " is not a valid XML document. It is possibly corrupted."; /** * text checked for in tests, {@value} */ static final String WARNING_INVALID_ROOT_ELEMENT = " is not a valid testsuite XML document"; /** * text checked for in tests, {@value} */ static final String WARNING_EMPTY_FILE = " is empty.\nThis can be caused by the test JVM exiting unexpectedly"; // CheckStyle:VisibilityModifier ON /** * Generate a report based on the document created by the merge. * @return the report */ public AggregateTransformer createReport() { AggregateTransformer transformer = new AggregateTransformer(this); transformers.add(transformer); return transformer; } /** * Set the name of the aggregegated results file. It must be relative * from the <tt>todir</tt> attribute. If not set it will use {@link #DEFAULT_FILENAME} * @param value the name of the file. * @see #setTodir(File) */ public void setTofile(String value) { toFile = value; } /** * Set the destination directory where the results should be written. If not * set if will use {@link #DEFAULT_DIR}. When given a relative directory * it will resolve it from the project directory. * @param value the directory where to write the results, absolute or * relative. */ public void setTodir(File value) { toDir = value; } /** * Add a new fileset containing the XML results to aggregate * @param fs the new fileset of xml results. */ public void addFileSet(FileSet fs) { filesets.add(fs); } /** * Aggregate all testsuites into a single document and write it to the * specified directory and file. * @throws BuildException thrown if there is a serious error while writing * the document. */ @Override public void execute() throws BuildException { Element rootElement = createDocument(); File destFile = getDestinationFile(); // write the document try { writeDOMTree(rootElement.getOwnerDocument(), destFile); } catch (IOException e) { throw new BuildException("Unable to write test aggregate to '" + destFile + "'", e); } // apply transformation for (AggregateTransformer transformer : transformers) { transformer.setXmlDocument(rootElement.getOwnerDocument()); transformer.transform(); } } /** * Get the full destination file where to write the result. It is made of * the <tt>todir</tt> and <tt>tofile</tt> attributes. * @return the destination file where should be written the result file. */ public File getDestinationFile() { if (toFile == null) { toFile = DEFAULT_FILENAME; } if (toDir == null) { toDir = getProject().resolveFile(DEFAULT_DIR); } return new File(toDir, toFile); } /** * Get all <code>.xml</code> files in the fileset. * * @return all files in the fileset that end with a '.xml'. */ protected File[] getFiles() { Project p = getProject(); return filesets.stream().flatMap(fs -> { DirectoryScanner ds = fs.getDirectoryScanner(p); ds.scan(); return Stream.of(ds.getIncludedFiles()) .filter(pathname -> pathname.endsWith(".xml")).map(pathname -> { return p.resolveFile( new File(ds.getBasedir(), pathname).getPath()); }); }).toArray(File[]::new); } //----- from now, the methods are all related to DOM tree manipulation /** * Write the DOM tree to a file. * @param doc the XML document to dump to disk. * @param file the filename to write the document to. Should obviously be a .xml file. * @throws IOException thrown if there is an error while writing the content. */ protected void writeDOMTree(Document doc, File file) throws IOException { try (OutputStream os = Files.newOutputStream(file.toPath()); PrintWriter wri = new PrintWriter(new OutputStreamWriter(new BufferedOutputStream(os), "UTF8"))) { wri.write("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n"); new DOMElementWriter().write(doc.getDocumentElement(), wri, 0, " "); wri.flush(); // writers do not throw exceptions, so check for them. if (wri.checkError()) { throw new IOException("Error while writing DOM content"); } } } /** * <p> Create a DOM tree. * Has 'testsuites' as firstchild and aggregates all * testsuite results that exists in the base directory. * @return the root element of DOM tree that aggregates all testsuites. */ protected Element createDocument() { // create the dom tree DocumentBuilder builder = getDocumentBuilder(); Document doc = builder.newDocument(); Element rootElement = doc.createElement(TESTSUITES); doc.appendChild(rootElement); generatedId = 0; // get all files and add them to the document File[] files = getFiles(); for (int i = 0; i < files.length; i++) { File file = files[i]; try { log("Parsing file: '" + file + "'", Project.MSG_VERBOSE); if (file.length() > 0) { Document testsuiteDoc = builder.parse(FileUtils .getFileUtils().toURI(files[i].getAbsolutePath())); Element elem = testsuiteDoc.getDocumentElement(); // make sure that this is REALLY a testsuite. if (TESTSUITE.equals(elem.getNodeName())) { addTestSuite(rootElement, elem); generatedId++; } else { //wrong root element name // issue a warning. log("the file " + file + WARNING_INVALID_ROOT_ELEMENT, Project.MSG_WARN); } } else { log("the file " + file + WARNING_EMPTY_FILE, Project.MSG_WARN); } } catch (SAXException e) { // a testcase might have failed and write a zero-length document, // It has already failed, but hey.... mm. just put a warning log("The file " + file + WARNING_IS_POSSIBLY_CORRUPTED, Project.MSG_WARN); log(StringUtils.getStackTrace(e), Project.MSG_DEBUG); } catch (IOException e) { log("Error while accessing file " + file + ": " + e.getMessage(), Project.MSG_ERR); log("Error while accessing file " + file + ": " + e.getMessage(), e, Project.MSG_VERBOSE); } } return rootElement; } /** * <p> Add a new testsuite node to the document. * The main difference is that it * split the previous fully qualified name into a package and a name. * <p> For example: <tt>org.apache.Whatever</tt> will be split into * <tt>org.apache</tt> and <tt>Whatever</tt>. * @param root the root element to which the <tt>testsuite</tt> node should * be appended. * @param testsuite the element to append to the given root. It will slightly * modify the original node to change the name attribute and add * a package one. */ protected void addTestSuite(Element root, Element testsuite) { String fullclassname = testsuite.getAttribute(ATTR_NAME); int pos = fullclassname.lastIndexOf('.'); // a missing . might imply no package at all. Don't get fooled. String pkgName = (pos == -1) ? "" : fullclassname.substring(0, pos); String classname = (pos == -1) ? fullclassname : fullclassname.substring(pos + 1); Element copy = (Element) DOMUtil.importNode(root, testsuite); // modify the name attribute and set the package copy.setAttribute(ATTR_NAME, classname); copy.setAttribute(ATTR_PACKAGE, pkgName); copy.setAttribute(ATTR_ID, Integer.toString(generatedId)); } /** * Create a new document builder. Will issue an <tt>ExceptionInitializerError</tt> * if something is going wrong. It is fatal anyway. * @todo factorize this somewhere else. It is duplicated code. * @return a new document builder to create a DOM */ private static DocumentBuilder getDocumentBuilder() { try { return DocumentBuilderFactory.newInstance().newDocumentBuilder(); } catch (Exception exc) { throw new ExceptionInInitializerError(exc); } } }