diff --git a/WHATSNEW b/WHATSNEW index cd960d225..041559255 100644 --- a/WHATSNEW +++ b/WHATSNEW @@ -37,6 +37,9 @@ Other changes: requested. Java11 removes support for CORBA and the switches have been removed from the rmic tool. + * A new junitlauncher task which support JUnit 5 test framework. + Bugzilla Report 61796 + Changes from Ant 1.10.1 TO Ant 1.10.2 ===================================== diff --git a/build.xml b/build.xml index e2fe0aebe..108c0a222 100644 --- a/build.xml +++ b/build.xml @@ -208,6 +208,20 @@ + + + + + + + + + + + + + + @@ -322,6 +336,7 @@ + @@ -405,6 +420,15 @@ + + + @@ -562,10 +586,12 @@ - + + + @@ -733,6 +759,7 @@ + diff --git a/fetch.xml b/fetch.xml index 166a2bb7a..9a98699a9 100644 --- a/fetch.xml +++ b/fetch.xml @@ -232,6 +232,24 @@ Set -Ddest=LOCATION on the command line + + + + + + + + + + + + @@ -367,5 +385,6 @@ Set -Ddest=LOCATION on the command line + depends="antunit,ivy,logging,junit,junitlauncher,xml,networking,regexp,antlr,bcel,jdepend,bsf,debugging,script, + javamail,jspc,jai,xz,netrexx,junit-engine-vintage,junit-engine-jupiter"/> diff --git a/lib/libraries.properties b/lib/libraries.properties index 3d4f4a156..cf8e93037 100644 --- a/lib/libraries.properties +++ b/lib/libraries.properties @@ -53,6 +53,11 @@ jdepend.version=2.9.1 jruby.version=1.6.8 junit.version=4.12 rhino.version=1.7.8 +junit-platform-launcher.version=1.1.0 +# Only used for internal tests in Ant project +junit-vintage-engine.version=5.1.0 +# Only used for internal tests in Ant project +junit-jupiter-engine.version=5.1.0 jsch.version=0.1.54 jython.version=2.7.0 # log4j 1.2.15 requires JMS and a few other Sun jars that are not in the m2 repo diff --git a/manual/Tasks/junitlauncher.html b/manual/Tasks/junitlauncher.html new file mode 100644 index 000000000..8603f5928 --- /dev/null +++ b/manual/Tasks/junitlauncher.html @@ -0,0 +1,481 @@ + + + + + JUnitLauncher Task + + + +

JUnitLauncher

+

Description

+ +

+ This task allows tests to be launched and run using the JUnit 5 framework. +

+

+ JUnit 5 introduced a newer set of APIs to write and launch tests. It also introduced + the concept of test engines. Test engines decide which classes are considered as testcases + and how they are executed. JUnit 5 supports running tests that have been written using + JUnit 4 constructs as well as tests that have been written using JUnit 5 constructs. + For more details about JUnit 5 itself, please refer to the JUnit 5 project's documentation at + https://junit.org/junit5/. +

+

+ The goal of this junitlauncher task is to allow launching the JUnit 5 + test launcher and building the test requests so that the selected tests can then be parsed + and executed by the test engine(s) supported by JUnit 5. This task in itself does not + understand what a test case is nor does it execute the tests itself. +

+

+ Note: This task depends on external libraries not included + in the Apache Ant distribution. See + Library Dependencies for more information. +

+

+ Note: + You must have the necessary JUnit 5 libraries in the classpath of the tests. At the time of + writing this documentation, the list of JUnit 5 platform libraries that are necessary to run the tests + are: +

    +
  • + junit-platform-commons.jar +
  • +
  • + junit-platform-engine.jar +
  • +
  • + junit-platform-launcher.jar +
  • +
+

+

+ Depending on the test engine(s) that you want to use in your tests, you will further need the following + libraries in the classpath +

+ +

+ For junit-vintage engine: +

    +
  • + junit-vintage-engine.jar +
  • +
  • + junit.jar (JUnit 4.x version) +
  • +
+

+

+ For junit-jupiter engine: +

    +
  • + junit-jupiter-api.jar +
  • +
  • + junit-jupiter-engine.jar +
  • +
  • + opentest4j.jar +
  • +
+ +

+

+ To have these in the test classpath, you can follow either of the following approaches: +

    +
  • Put all these relevant jars along with the ant-junitlauncher.jar in ANT_HOME/lib + directory +
  • +
  • OR Leave ant-junitlauncher.jar in the ANT_HOME/lib directory and include all + other relevant jars in the classpath by passing them as a -lib option, while invoking Ant +
  • +
+

+ +

+ Tests are defined by nested elements like test, + testclasses tags (see nested + elements).

+ +

Parameters

+ + + + + + + + + + + + + + + + +
AttributeDescriptionRequired
haltOnFailureA value of true implies that build has to stop + if any failure occurs in any of the tests. JUnit 5 classifies failures + as both assertion failures as well as exceptions that get thrown during + test execution. As such, this task too considers both these cases as + failures and doesn't distinguish one from another. + No; default is false.
failurePropertyThe name of a property to set in the event of a failure + (exceptions in tests are considered failures as well). + No.
+ +

Nested Elements

+ +

classpath

+

+ The nested <classpath> element that represents a + PATH like structure can be used to configure + the task to use this classpath for finding and running the tests. This classpath + will be used for: +

    +
  • Finding the test classes to execute
  • +
  • Finding the JUnit 5 framework libraries (which include the API jars and test engine jars). The complete + set of jars that are relevant in JUnit 5 framework are listed in the dependecies + section +
  • +
+If the classpath element isn't configured for the task, then the classpath of +Ant itself will be used for finding the test classes and JUnit 5 libraries. + +

+ +

listener

+ +

+ The junitlauncher task can be configured with listener(s) to listen + to test execution events (such as a test execution starting, completing etc...). The listener + is expected to be a class which implements the org.junit.platform.launcher.TestExecutionListener. + This TestExecutionListener interface is an API exposed by the JUnit 5 platform APIs and isn't + specific to Ant. As such, you can use any existing implementation of TestExecutionListener in + this task. +

+ +
Test result formatter
+

+ junitlauncher provides a way where the test execution results can be formatted and presented + in a way that's customizable. The task allows for configuring test result formatters, through the use of + listener element. As noted previously, the listener element expects the listener + to implement the org.junit.platform.launcher.TestExecutionListener interface. Typically, result + formatters need a bit more configuration details to be fed to them, during the test execution - details + like where to write out the formatted result. Any such listener can optionally implement + the org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter interface. This interface + is specific to Ant junitlauncher task and it extends the org.junit.platform.launcher.TestExecutionListener + interface +

+

+ The junitlauncher task comes with the following pre-defined test result formatter types: +

    +
  • + legacy-plain : This formatter prints a short statistics line for all test cases. +
  • +
  • + legacy-brief : This formatter prints information for tests that failed or were skipped. +
  • +
  • + legacy-xml : This formatter prints statistics for the tests in xml format. +
  • +
+NOTE: Each of these formatters, that are named "legacy" try, and format the results to be almost similar to +what the junit task's formatters used to do. Furthermore, the legacy-xml formatters +generates the XML to comply with the same schema that the junit task's XML formatter used to follow. +As a result, the XML generated by this formatter, can be used as-is by the junitreport task. + +

+ +The listener element supports the following attributes: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionRequired
typeUse a predefined formatter (either + legacy-xml, legacy-plain or legacy-brief). + Exactly one of these
classnameName of a listener class which implements org.junit.platform.launcher.TestExecutionListener + or the org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter interface +
resultFileThe file name to which the formatted result needs to be written to. This attribute is only + relevant + when the listener class implements the org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter + interface. +

If no value is specified for this attribute and the listener implements the + org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter then the file name + will be defaulted + to and will be of the form TEST-<testname>.<formatter-specific-extension> + (ex: TEST-org.myapp.SomeTest.xml for the legacy-xml type formatter) +

+
No
sendSysOutIf set to true then the listener will be passed the stdout content + generated by the test(s). This attribute is relevant only if the listener + class implements the org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter + interface. + No; defaults to false
sendSysErrIf set to true then the listener will be passed the stderr content + generated by the test(s). This attribute is relevant only if the listener + class implements the org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter + interface. + No; defaults to false
ifOnly use this listener if the named property is set. + No
unlessOnly use this listener if the named property is + not + set. + No
+ +

test

+ +

Defines a single test class.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionRequired
nameFully qualified name of the test class.Yes
methodsComma-separated list of names of test case methods to execute. + If this is specified, then only these test methods from the test class will be + executed. + No
haltOnFailureStop the build process if a failure occurs during the test + run (exceptions are considered as failures too). + Overrides value set on junitlauncher element. + No
failurePropertyThe name of a property to set in the event of a failure + (exceptions are considered failures as well). Overrides value set on + junitlauncher element. + No
outputDirDirectory to write the reports to.No; default is the base directory of the project.
ifOnly run this test if the named property is set. + No
unlessOnly run this test if the named property is not + set. + No
+ +

+ Tests can define their own listeners via nested listener elements. +

+ +

testclasses

+ +

Define a number of tests based on pattern matching.

+ +

+ testclasses collects the included resources from any number + of nested Resource Collections. It then + selects each resource whose name ends in .class. These classes are then passed on to the + JUnit 5 platform for it to decide and run them as tests. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionRequired
haltOnFailureStop the build process if a failure occurs during the test + run (exceptions are considered as failures too). + Overrides value set on junitlauncher element. + No
failurePropertyThe name of a property to set in the event of a failure + (exceptions are considered failures as well). Overrides value set on + junitlauncher element. + No
outputDirDirectory to write the reports to.No; default is the base directory of the project.
ifOnly run the tests if the named property is set. + No
unlessOnly run the tests if the named property is not + set. + No
+ +

+ testclasses can define their own listeners via nested listener elements. +

+ +

Examples

+ +
+<path id="test.classpath">
+    ...
+</path>
+
+<junitlauncher>
+    <classpath refid="test.classpath"/>
+    <test name="org.myapp.SimpleTest"/>
+</junitlauncher>
+
+
+ +

+ Launches the JUnit 5 platform to run the org.myapp.SimpleTest test +

+ +
+<junitlauncher>
+    <classpath refid="test.classpath"/>
+    <test name="org.myapp.SimpleTest" haltOnFailure="true"/>
+    <test name="org.myapp.AnotherTest"/>
+</junitlauncher>
+
+ +

+ Launches the JUnit 5 platform to run the org.myapp.SimpleTest and the + org.myapp.AnotherTest tests. The build process will be stopped if any + test, in the org.myapp.SimpleTest, fails. +

+ +
+<junitlauncher>
+    <classpath refid="test.classpath"/>
+    <test name="org.myapp.SimpleTest" methods="testFoo, testBar"/>
+</junitlauncher>
+
+

+ Launches the JUnit 5 platform to run only the testFoo and testBar methods of the + org.myapp.SimpleTest test class. +

+ +
+<junitlauncher>
+    <classpath refid="test.classpath"/>
+
+    <testclasses outputdir="${output.dir}">
+        <fileset dir="${build.classes.dir}">
+            <include name="org/example/**/tests/**/"/>
+        </fileset>
+    </testclasses>
+</junitlauncher>
+
+ +

+ Selects any .class files that match the org/example/**/tests/**/ fileset + filter, under the ${build.classes.dir} and passes those classes to the JUnit 5 platform for + execution as tests. +

+ +
+<junitlauncher>
+    <classpath refid="test.classpath"/>
+
+    <testclasses outputdir="${output.dir}">
+        <fileset dir="${build.classes.dir}">
+            <include name="org/example/**/tests/**/"/>
+        </fileset>
+        <listener type="legacy-xml" sendSysOut="true" sendSysErr="true"/>
+        <listener type="legacy-plain" sendSysOut="true" />
+    </testclasses>
+</junitlauncher>
+
+

+ Selects any .class files that match the org/example/**/tests/**/ fileset + filter, under the ${build.classes.dir} and passes those classes to the JUnit 5 platform for + execution as tests. Test results will be written out to the ${output.dir} by the + legacy-xml and legacy-plain formatters, in separate files. + Furthermore, both the legacy-xml and the legacy-plain + listeners, above, are configured to receive the standard output content generated by the tests. + The legacy-xml listener is configured to receive standard error content as well. + +

+ + + + diff --git a/src/etc/testcases/taskdefs/optional/junitlauncher.xml b/src/etc/testcases/taskdefs/optional/junitlauncher.xml new file mode 100644 index 000000000..ccae7ae66 --- /dev/null +++ b/src/etc/testcases/taskdefs/optional/junitlauncher.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/org/apache/tools/ant/taskdefs/defaults.properties b/src/main/org/apache/tools/ant/taskdefs/defaults.properties index 8db1ebcf4..7b4781c37 100644 --- a/src/main/org/apache/tools/ant/taskdefs/defaults.properties +++ b/src/main/org/apache/tools/ant/taskdefs/defaults.properties @@ -160,6 +160,7 @@ jjdoc=org.apache.tools.ant.taskdefs.optional.javacc.JJDoc jjtree=org.apache.tools.ant.taskdefs.optional.javacc.JJTree junit=org.apache.tools.ant.taskdefs.optional.junit.JUnitTask junitreport=org.apache.tools.ant.taskdefs.optional.junit.XMLResultAggregator +junitlauncher=org.apache.tools.ant.taskdefs.optional.junitlauncher.JUnitLauncherTask native2ascii=org.apache.tools.ant.taskdefs.optional.Native2Ascii netrexxc=org.apache.tools.ant.taskdefs.optional.NetRexxC propertyfile=org.apache.tools.ant.taskdefs.optional.PropertyFile diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java new file mode 100644 index 000000000..4d1aa1173 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java @@ -0,0 +1,295 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.Project; +import org.apache.tools.ant.util.FileUtils; +import org.junit.platform.engine.TestSource; +import org.junit.platform.engine.support.descriptor.ClassSource; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Writer; +import java.nio.BufferOverflowException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +/** + * Contains some common behaviour that's used by our internal {@link TestResultFormatter}s + */ +abstract class AbstractJUnitResultFormatter implements TestResultFormatter { + + + protected static String NEW_LINE = System.getProperty("line.separator"); + protected TestExecutionContext context; + + private SysOutErrContentStore sysOutStore; + private SysOutErrContentStore sysErrStore; + + @Override + public void sysOutAvailable(final byte[] data) { + if (this.sysOutStore == null) { + this.sysOutStore = new SysOutErrContentStore(true); + } + try { + this.sysOutStore.store(data); + } catch (IOException e) { + handleException(e); + return; + } + } + + @Override + public void sysErrAvailable(final byte[] data) { + if (this.sysErrStore == null) { + this.sysErrStore = new SysOutErrContentStore(false); + } + try { + this.sysErrStore.store(data); + } catch (IOException e) { + handleException(e); + return; + } + } + + @Override + public void setContext(final TestExecutionContext context) { + this.context = context; + } + + /** + * @return Returns true if there's any stdout data, that was generated during the + * tests, is available for use. Else returns false. + */ + boolean hasSysOut() { + return this.sysOutStore != null && this.sysOutStore.hasData(); + } + + /** + * @return Returns true if there's any stderr data, that was generated during the + * tests, is available for use. Else returns false. + */ + boolean hasSysErr() { + return this.sysErrStore != null && this.sysErrStore.hasData(); + } + + /** + * @return Returns a {@link Reader} for reading any stdout data that was generated + * during the test execution. It is expected that the {@link #hasSysOut()} be first + * called to see if any such data is available and only if there is, then this method + * be called + * @throws IOException If there's any I/O problem while creating the {@link Reader} + */ + Reader getSysOutReader() throws IOException { + return this.sysOutStore.getReader(); + } + + /** + * @return Returns a {@link Reader} for reading any stderr data that was generated + * during the test execution. It is expected that the {@link #hasSysErr()} be first + * called to see if any such data is available and only if there is, then this method + * be called + * @throws IOException If there's any I/O problem while creating the {@link Reader} + */ + Reader getSysErrReader() throws IOException { + return this.sysErrStore.getReader(); + } + + /** + * Writes out any stdout data that was generated during the + * test execution. If there was no such data then this method just returns. + * + * @param writer The {@link Writer} to use. Cannot be null. + * @throws IOException If any I/O problem occurs during writing the data + */ + void writeSysOut(final Writer writer) throws IOException { + Objects.requireNonNull(writer, "Writer cannot be null"); + this.writeFrom(this.sysOutStore, writer); + } + + /** + * Writes out any stderr data that was generated during the + * test execution. If there was no such data then this method just returns. + * + * @param writer The {@link Writer} to use. Cannot be null. + * @throws IOException If any I/O problem occurs during writing the data + */ + void writeSysErr(final Writer writer) throws IOException { + Objects.requireNonNull(writer, "Writer cannot be null"); + this.writeFrom(this.sysErrStore, writer); + } + + static Optional traverseAndFindTestClass(final TestPlan testPlan, final TestIdentifier testIdentifier) { + if (isTestClass(testIdentifier).isPresent()) { + return Optional.of(testIdentifier); + } + final Optional parent = testPlan.getParent(testIdentifier); + return parent.isPresent() ? traverseAndFindTestClass(testPlan, parent.get()) : Optional.empty(); + } + + static Optional isTestClass(final TestIdentifier testIdentifier) { + if (testIdentifier == null) { + return Optional.empty(); + } + final Optional source = testIdentifier.getSource(); + if (!source.isPresent()) { + return Optional.empty(); + } + final TestSource testSource = source.get(); + if (testSource instanceof ClassSource) { + return Optional.of((ClassSource) testSource); + } + return Optional.empty(); + } + + private void writeFrom(final SysOutErrContentStore store, final Writer writer) throws IOException { + final char[] chars = new char[1024]; + int numRead = -1; + try (final Reader reader = store.getReader()) { + while ((numRead = reader.read(chars)) != -1) { + writer.write(chars, 0, numRead); + } + } + } + + @Override + public void close() throws IOException { + FileUtils.close(this.sysOutStore); + FileUtils.close(this.sysErrStore); + } + + protected void handleException(final Throwable t) { + // we currently just log it and move on. + this.context.getProject().ifPresent((p) -> p.log("Exception in listener " + + AbstractJUnitResultFormatter.this.getClass().getName(), t, Project.MSG_DEBUG)); + } + + + /* + A "store" for sysout/syserr content that gets sent to the AbstractJUnitResultFormatter. + This store first uses a relatively decent sized in-memory buffer for storing the sysout/syserr + content. This in-memory buffer will be used as long as it can fit in the new content that + keeps coming in. When the size limit is reached, this store switches to a file based store + by creating a temporarily file and writing out the already in-memory held buffer content + and any new content that keeps arriving to this store. Once the file has been created, + the in-memory buffer will never be used any more and in fact is destroyed as soon as the + file is created. + Instances of this class are not thread-safe and users of this class are expected to use necessary thread + safety guarantees, if they want to use an instance of this class by multiple threads. + */ + private static final class SysOutErrContentStore implements Closeable { + private static final int DEFAULT_CAPACITY_IN_BYTES = 50 * 1024; // 50 KB + private static final Reader EMPTY_READER = new Reader() { + @Override + public int read(final char[] cbuf, final int off, final int len) throws IOException { + return -1; + } + + @Override + public void close() throws IOException { + } + }; + + private final String tmpFileSuffix; + private ByteBuffer inMemoryStore = ByteBuffer.allocate(DEFAULT_CAPACITY_IN_BYTES); + private boolean usingFileStore = false; + private Path filePath; + private FileOutputStream fileOutputStream; + + private SysOutErrContentStore(final boolean isSysOut) { + this.tmpFileSuffix = isSysOut ? ".sysout" : ".syserr"; + } + + private void store(final byte[] data) throws IOException { + if (this.usingFileStore) { + this.storeToFile(data, 0, data.length); + return; + } + // we haven't yet created a file store and the data can fit in memory, + // so we write it in our buffer + try { + this.inMemoryStore.put(data); + return; + } catch (BufferOverflowException boe) { + // the buffer capacity can't hold this incoming data, so this + // incoming data hasn't been transferred to the buffer. let's + // now fall back to a file store + this.usingFileStore = true; + } + // since the content couldn't be transferred into in-memory buffer, + // we now create a file and transfer already (previously) stored in-memory + // content into that file, before finally transferring this new content + // into the file too. We then finally discard this in-memory buffer and + // just keep using the file store instead + this.fileOutputStream = createFileStore(); + // first the existing in-memory content + storeToFile(this.inMemoryStore.array(), 0, this.inMemoryStore.position()); + storeToFile(data, 0, data.length); + // discard the in-memory store + this.inMemoryStore = null; + } + + private void storeToFile(final byte[] data, final int offset, final int length) throws IOException { + if (this.fileOutputStream == null) { + // no backing file was created so we can't do anything + return; + } + this.fileOutputStream.write(data, offset, length); + } + + private FileOutputStream createFileStore() throws IOException { + this.filePath = Files.createTempFile(null, this.tmpFileSuffix); + this.filePath.toFile().deleteOnExit(); + return new FileOutputStream(this.filePath.toFile()); + } + + /* + * Returns a Reader for reading the sysout/syserr content. If there's no data + * available in this store, then this returns a Reader which when used for read operations, + * will immediately indicate an EOF. + */ + private Reader getReader() throws IOException { + if (this.usingFileStore && this.filePath != null) { + // we use a FileReader here so that we can use the system default character + // encoding for reading the contents on sysout/syserr stream, since that's the + // encoding that System.out/System.err uses to write out the messages + return new BufferedReader(new FileReader(this.filePath.toFile())); + } + if (this.inMemoryStore != null) { + return new InputStreamReader(new ByteArrayInputStream(this.inMemoryStore.array(), 0, this.inMemoryStore.position())); + } + // no data to read, so we return an "empty" reader + return EMPTY_READER; + } + + /* + * Returns true if this store has any data (either in-memory or in a file). Else + * returns false. + */ + private boolean hasData() { + if (this.inMemoryStore != null && this.inMemoryStore.position() > 0) { + return true; + } + if (this.usingFileStore && this.filePath != null) { + return true; + } + return false; + } + + @Override + public void close() throws IOException { + this.inMemoryStore = null; + FileUtils.close(this.fileOutputStream); + FileUtils.delete(this.filePath.toFile()); + } + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTask.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTask.java new file mode 100644 index 000000000..ac4ef44c5 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTask.java @@ -0,0 +1,537 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.AntClassLoader; +import org.apache.tools.ant.BuildException; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.Task; +import org.apache.tools.ant.types.Path; +import org.apache.tools.ant.util.FileUtils; +import org.apache.tools.ant.util.KeepAliveOutputStream; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * An Ant {@link Task} responsible for launching the JUnit platform for running tests. + * This requires a minimum of JUnit 5, since that's the version in which the JUnit platform launcher + * APIs were introduced. + *

+ * This task in itself doesn't run the JUnit tests, instead the sole responsibility of + * this task is to setup the JUnit platform launcher, build requests, launch those requests and then parse the + * result of the execution to present in a way that's been configured on this Ant task. + *

+ *

+ * Furthermore, this task allows users control over which classes to select for passing on to the JUnit 5 + * platform for test execution. It however, is solely the JUnit 5 platform, backed by test engines that + * decide and execute the tests. + * + * @see JUnit 5 documentation for more details + * on how JUnit manages the platform and the test engines. + */ +public class JUnitLauncherTask extends Task { + + private Path classPath; + private boolean haltOnFailure; + private String failureProperty; + private final List tests = new ArrayList<>(); + private final List listeners = new ArrayList<>(); + + public JUnitLauncherTask() { + } + + @Override + public void execute() throws BuildException { + final ClassLoader previousClassLoader = Thread.currentThread().getContextClassLoader(); + try { + final ClassLoader executionCL = createClassLoaderForTestExecution(); + Thread.currentThread().setContextClassLoader(executionCL); + final Launcher launcher = LauncherFactory.create(); + final List requests = buildTestRequests(); + for (final TestRequest testRequest : requests) { + try { + final TestDefinition test = testRequest.getOwner(); + final LauncherDiscoveryRequest request = testRequest.getDiscoveryRequest().build(); + final List testExecutionListeners = new ArrayList<>(); + // a listener that we always put at the front of list of listeners + // for this request. + final Listener firstListener = new Listener(); + // we always enroll the summary generating listener, to the request, so that we + // get to use some of the details of the summary for our further decision making + testExecutionListeners.add(firstListener); + testExecutionListeners.addAll(getListeners(testRequest, executionCL)); + final PrintStream originalSysOut = System.out; + final PrintStream originalSysErr = System.err; + try { + firstListener.switchedSysOutHandle = trySwitchSysOutErr(testRequest, StreamType.SYS_OUT); + firstListener.switchedSysErrHandle = trySwitchSysOutErr(testRequest, StreamType.SYS_ERR); + launcher.execute(request, testExecutionListeners.toArray(new TestExecutionListener[testExecutionListeners.size()])); + } finally { + // switch back sysout/syserr to the original + try { + System.setOut(originalSysOut); + } catch (Exception e) { + // ignore + } + try { + System.setErr(originalSysErr); + } catch (Exception e) { + // ignore + } + } + handleTestExecutionCompletion(test, firstListener.getSummary()); + } finally { + try { + testRequest.close(); + } catch (Exception e) { + // log and move on + log("Failed to cleanly close test request", e, Project.MSG_DEBUG); + } + } + } + } finally { + Thread.currentThread().setContextClassLoader(previousClassLoader); + } + } + + /** + * Adds the {@link Path} to the classpath which will be used for execution of the tests + * + * @param path The classpath + */ + public void addConfiguredClassPath(final Path path) { + if (this.classPath == null) { + // create a "wrapper" path which can hold on to multiple + // paths that get passed to this method (if at all the task in the build is + // configured with multiple classpaht elements) + this.classPath = new Path(getProject()); + } + this.classPath.add(path); + } + + /** + * Adds a {@link SingleTestClass} that will be passed on to the underlying JUnit platform + * for possible execution of the test + * + * @param test The test + */ + public void addConfiguredTest(final SingleTestClass test) { + this.preConfigure(test); + this.tests.add(test); + } + + /** + * Adds {@link TestClasses} that will be passed on to the underlying JUnit platform for + * possible execution of the tests + * + * @param testClasses The test classes + */ + public void addConfiguredTestClasses(final TestClasses testClasses) { + this.preConfigure(testClasses); + this.tests.add(testClasses); + } + + /** + * Adds a {@link ListenerDefinition listener} which will be enrolled for listening to test + * execution events + * + * @param listener The listener + */ + public void addConfiguredListener(final ListenerDefinition listener) { + this.listeners.add(listener); + } + + public void setHaltonfailure(final boolean haltonfailure) { + this.haltOnFailure = haltonfailure; + } + + public void setFailureProperty(final String failureProperty) { + this.failureProperty = failureProperty; + } + + private void preConfigure(final TestDefinition test) { + if (test.getHaltOnFailure() == null) { + test.setHaltOnFailure(this.haltOnFailure); + } + if (test.getFailureProperty() == null) { + test.setFailureProperty(this.failureProperty); + } + } + + private List buildTestRequests() { + if (this.tests.isEmpty()) { + return Collections.emptyList(); + } + final List requests = new ArrayList<>(); + for (final TestDefinition test : this.tests) { + final List testRequests = test.createTestRequests(this); + if (testRequests == null || testRequests.isEmpty()) { + continue; + } + requests.addAll(testRequests); + } + return requests; + } + + private List getListeners(final TestRequest testRequest, final ClassLoader classLoader) { + final TestDefinition test = testRequest.getOwner(); + final List applicableListenerElements = test.getListeners().isEmpty() ? this.listeners : test.getListeners(); + final List listeners = new ArrayList<>(); + final Project project = getProject(); + for (final ListenerDefinition applicableListener : applicableListenerElements) { + if (!applicableListener.shouldUse(project)) { + log("Excluding listener " + applicableListener.getClassName() + " since it's not applicable" + + " in the context of project " + project, Project.MSG_DEBUG); + continue; + } + final TestExecutionListener listener = requireTestExecutionListener(applicableListener, classLoader); + if (listener instanceof TestResultFormatter) { + // setup/configure the result formatter + setupResultFormatter(testRequest, applicableListener, (TestResultFormatter) listener); + } + listeners.add(listener); + } + return listeners; + } + + private void setupResultFormatter(final TestRequest testRequest, final ListenerDefinition formatterDefinition, + final TestResultFormatter resultFormatter) { + + testRequest.closeUponCompletion(resultFormatter); + // set the execution context + resultFormatter.setContext(new InVMExecution()); + // set the destination output stream for writing out the formatted result + final TestDefinition test = testRequest.getOwner(); + final java.nio.file.Path outputDir = test.getOutputDir() != null ? Paths.get(test.getOutputDir()) : getProject().getBaseDir().toPath(); + final String filename = formatterDefinition.requireResultFile(test); + final java.nio.file.Path resultOutputFile = Paths.get(outputDir.toString(), filename); + try { + final OutputStream resultOutputStream = Files.newOutputStream(resultOutputFile); + // enroll the output stream to be closed when the execution of the TestRequest completes + testRequest.closeUponCompletion(resultOutputStream); + resultFormatter.setDestination(new KeepAliveOutputStream(resultOutputStream)); + } catch (IOException e) { + throw new BuildException(e); + } + // check if system.out/system.err content needs to be passed on to the listener + if (formatterDefinition.shouldSendSysOut()) { + testRequest.addSysOutInterest(resultFormatter); + } + if (formatterDefinition.shouldSendSysErr()) { + testRequest.addSysErrInterest(resultFormatter); + } + } + + private TestExecutionListener requireTestExecutionListener(final ListenerDefinition listener, final ClassLoader classLoader) { + final String className = listener.getClassName(); + if (className == null || className.trim().isEmpty()) { + throw new BuildException("classname attribute value is missing on listener element"); + } + final Class klass; + try { + klass = Class.forName(className, false, classLoader); + } catch (ClassNotFoundException e) { + throw new BuildException("Failed to load listener class " + className, e); + } + if (!TestExecutionListener.class.isAssignableFrom(klass)) { + throw new BuildException("Listener class " + className + " is not of type " + TestExecutionListener.class.getName()); + } + try { + return TestExecutionListener.class.cast(klass.newInstance()); + } catch (Exception e) { + throw new BuildException("Failed to create an instance of listener " + className, e); + } + } + + private void handleTestExecutionCompletion(final TestDefinition test, final TestExecutionSummary summary) { + final boolean hasTestFailures = summary.getTestsFailedCount() != 0; + try { + if (hasTestFailures && test.getFailureProperty() != null) { + // if there are test failures and the test is configured to set a property in case + // of failure, then set the property to true + getProject().setNewProperty(test.getFailureProperty(), "true"); + } + } finally { + if (hasTestFailures && test.isHaltOnFailure()) { + // if the test is configured to halt on test failures, throw a build error + final String errorMessage; + if (test instanceof NamedTest) { + errorMessage = "Test " + ((NamedTest) test).getName() + " has " + summary.getTestsFailedCount() + " failure(s)"; + } else { + errorMessage = "Some test(s) have failure(s)"; + } + throw new BuildException(errorMessage); + } + } + } + + private ClassLoader createClassLoaderForTestExecution() { + if (this.classPath == null) { + return this.getClass().getClassLoader(); + } + return new AntClassLoader(this.getClass().getClassLoader(), getProject(), this.classPath, true); + } + + private Optional trySwitchSysOutErr(final TestRequest testRequest, final StreamType streamType) { + switch (streamType) { + case SYS_OUT: { + if (!testRequest.interestedInSysOut()) { + return Optional.empty(); + } + break; + } + case SYS_ERR: { + if (!testRequest.interestedInSysErr()) { + return Optional.empty(); + } + break; + } + default: { + // unknown, but no need to error out, just be lenient + // and return back + return Optional.empty(); + } + } + final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + final PipedInputStream pipedInputStream; + try { + pipedInputStream = new PipedInputStream(pipedOutputStream); + } catch (IOException ioe) { + // log and return + return Optional.empty(); + } + final PrintStream printStream = new PrintStream(pipedOutputStream, true); + final SysOutErrStreamReader streamer; + switch (streamType) { + case SYS_OUT: { + System.setOut(new PrintStream(printStream)); + streamer = new SysOutErrStreamReader(this, pipedInputStream, + StreamType.SYS_OUT, testRequest.getSysOutInterests()); + final Thread sysOutStreamer = new Thread(streamer); + sysOutStreamer.setDaemon(true); + sysOutStreamer.setName("junitlauncher-sysout-stream-reader"); + sysOutStreamer.setUncaughtExceptionHandler((t, e) -> this.log("Failed in sysout streaming", e, Project.MSG_INFO)); + sysOutStreamer.start(); + break; + } + case SYS_ERR: { + System.setErr(new PrintStream(printStream)); + streamer = new SysOutErrStreamReader(this, pipedInputStream, + StreamType.SYS_ERR, testRequest.getSysErrInterests()); + final Thread sysErrStreamer = new Thread(streamer); + sysErrStreamer.setDaemon(true); + sysErrStreamer.setName("junitlauncher-syserr-stream-reader"); + sysErrStreamer.setUncaughtExceptionHandler((t, e) -> this.log("Failed in syserr streaming", e, Project.MSG_INFO)); + sysErrStreamer.start(); + break; + } + default: { + return Optional.empty(); + } + } + return Optional.of(new SwitchedStreamHandle(pipedOutputStream, streamer)); + } + + private enum StreamType { + SYS_OUT, + SYS_ERR + } + + private static final class SysOutErrStreamReader implements Runnable { + private static final byte[] EMPTY = new byte[0]; + + private final JUnitLauncherTask task; + private final InputStream sourceStream; + private final StreamType streamType; + private final Collection resultFormatters; + private volatile SysOutErrContentDeliverer contentDeliverer; + + SysOutErrStreamReader(final JUnitLauncherTask task, final InputStream source, final StreamType streamType, final Collection resultFormatters) { + this.task = task; + this.sourceStream = source; + this.streamType = streamType; + this.resultFormatters = resultFormatters; + } + + @Override + public void run() { + final SysOutErrContentDeliverer streamContentDeliver = new SysOutErrContentDeliverer(this.streamType, this.resultFormatters); + final Thread deliveryThread = new Thread(streamContentDeliver); + deliveryThread.setName("junitlauncher-" + (this.streamType == StreamType.SYS_OUT ? "sysout" : "syserr") + "-stream-deliverer"); + deliveryThread.setDaemon(true); + deliveryThread.start(); + this.contentDeliverer = streamContentDeliver; + int numRead = -1; + final byte[] data = new byte[1024]; + try { + while ((numRead = this.sourceStream.read(data)) != -1) { + final byte[] copy = Arrays.copyOf(data, numRead); + streamContentDeliver.availableData.offer(copy); + } + } catch (IOException e) { + task.log("Failed while streaming " + (this.streamType == StreamType.SYS_OUT ? "sysout" : "syserr") + " data", + e, Project.MSG_INFO); + return; + } finally { + streamContentDeliver.stop = true; + // just "wakeup" the delivery thread, to take into account + // those race conditions, where that other thread didn't yet + // notice that it was asked to stop and has now gone into a + // X amount of wait, waiting for any new data + streamContentDeliver.availableData.offer(EMPTY); + } + } + } + + private static final class SysOutErrContentDeliverer implements Runnable { + private volatile boolean stop; + private final Collection resultFormatters; + private final StreamType streamType; + private final BlockingQueue availableData = new LinkedBlockingQueue<>(); + private final CountDownLatch completionLatch = new CountDownLatch(1); + + SysOutErrContentDeliverer(final StreamType streamType, final Collection resultFormatters) { + this.streamType = streamType; + this.resultFormatters = resultFormatters; + } + + @Override + public void run() { + try { + while (!this.stop) { + final byte[] streamData; + try { + streamData = this.availableData.poll(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (streamData != null) { + deliver(streamData); + } + } + // drain it + final List remaining = new ArrayList<>(); + this.availableData.drainTo(remaining); + if (!remaining.isEmpty()) { + for (final byte[] data : remaining) { + deliver(data); + } + } + } finally { + this.completionLatch.countDown(); + } + } + + private void deliver(final byte[] data) { + if (data == null || data.length == 0) { + return; + } + for (final TestResultFormatter resultFormatter : this.resultFormatters) { + // send it to the formatter + switch (streamType) { + case SYS_OUT: { + resultFormatter.sysOutAvailable(data); + break; + } + case SYS_ERR: { + resultFormatter.sysErrAvailable(data); + break; + } + } + } + } + } + + private final class SwitchedStreamHandle { + private final PipedOutputStream outputStream; + private final SysOutErrStreamReader streamReader; + + SwitchedStreamHandle(final PipedOutputStream outputStream, final SysOutErrStreamReader streamReader) { + this.streamReader = streamReader; + this.outputStream = outputStream; + } + } + + private final class Listener extends SummaryGeneratingListener { + private Optional switchedSysOutHandle; + private Optional switchedSysErrHandle; + + @Override + public void testPlanExecutionFinished(final TestPlan testPlan) { + super.testPlanExecutionFinished(testPlan); + // now that the test plan execution is finished, close the switched sysout/syserr output streams + // and wait for the sysout and syserr content delivery, to result formatters, to finish + if (this.switchedSysOutHandle.isPresent()) { + final SwitchedStreamHandle sysOut = this.switchedSysOutHandle.get(); + try { + closeAndWait(sysOut); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + if (this.switchedSysErrHandle.isPresent()) { + final SwitchedStreamHandle sysErr = this.switchedSysErrHandle.get(); + try { + closeAndWait(sysErr); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void closeAndWait(final SwitchedStreamHandle handle) throws InterruptedException { + FileUtils.close(handle.outputStream); + if (handle.streamReader.contentDeliverer == null) { + return; + } + // wait for a few seconds + handle.streamReader.contentDeliverer.completionLatch.await(2, TimeUnit.SECONDS); + } + } + + private final class InVMExecution implements TestExecutionContext { + + private final Properties props; + + InVMExecution() { + this.props = new Properties(); + this.props.putAll(JUnitLauncherTask.this.getProject().getProperties()); + } + + @Override + public Properties getProperties() { + return this.props; + } + + @Override + public Optional getProject() { + return Optional.of(JUnitLauncherTask.this.getProject()); + } + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java new file mode 100644 index 000000000..d5d46705c --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java @@ -0,0 +1,17 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.launcher.TestIdentifier; + +/** + * A {@link TestResultFormatter} which prints a brief statistic for tests that have + * failed, aborted or skipped + */ +class LegacyBriefResultFormatter extends LegacyPlainResultFormatter implements TestResultFormatter { + + @Override + protected boolean shouldReportExecutionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) { + final TestExecutionResult.Status resultStatus = testExecutionResult.getStatus(); + return resultStatus == TestExecutionResult.Status.ABORTED || resultStatus == TestExecutionResult.Status.FAILED; + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java new file mode 100644 index 000000000..49ce7e387 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java @@ -0,0 +1,294 @@ +/* + * 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.junitlauncher; + +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.engine.reporting.ReportEntry; +import org.junit.platform.engine.support.descriptor.ClassSource; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + + +/** + * A {@link TestResultFormatter} which prints a short statistic for each of the tests + */ +class LegacyPlainResultFormatter extends AbstractJUnitResultFormatter implements TestResultFormatter { + + private OutputStream outputStream; + private final Map testIds = new ConcurrentHashMap<>(); + private TestPlan testPlan; + private BufferedWriter writer; + + @Override + public void testPlanExecutionStarted(final TestPlan testPlan) { + this.testPlan = testPlan; + } + + @Override + public void testPlanExecutionFinished(final TestPlan testPlan) { + for (final Map.Entry entry : this.testIds.entrySet()) { + final TestIdentifier testIdentifier = entry.getKey(); + if (!isTestClass(testIdentifier).isPresent()) { + // we are not interested in anything other than a test "class" in this section + continue; + } + final Stats stats = entry.getValue(); + final StringBuilder sb = new StringBuilder("Tests run: ").append(stats.numTestsRun.get()); + sb.append(", Failures: ").append(stats.numTestsFailed.get()); + sb.append(", Skipped: ").append(stats.numTestsSkipped.get()); + sb.append(", Aborted: ").append(stats.numTestsAborted.get()); + final long timeElapsed = stats.endedAt - stats.startedAt; + sb.append(", Time elapsed: "); + if (timeElapsed < 1000) { + sb.append(timeElapsed).append(" milli sec(s)"); + } else { + sb.append(TimeUnit.SECONDS.convert(timeElapsed, TimeUnit.MILLISECONDS)).append(" sec(s)"); + } + try { + this.writer.write(sb.toString()); + this.writer.newLine(); + } catch (IOException ioe) { + handleException(ioe); + return; + } + } + // write out sysout and syserr content if any + try { + if (this.hasSysOut()) { + this.writer.write("------------- Standard Output ---------------"); + this.writer.newLine(); + writeSysOut(writer); + this.writer.write("------------- ---------------- ---------------"); + this.writer.newLine(); + } + if (this.hasSysErr()) { + this.writer.write("------------- Standard Error ---------------"); + this.writer.newLine(); + writeSysErr(writer); + this.writer.write("------------- ---------------- ---------------"); + this.writer.newLine(); + } + } catch (IOException ioe) { + handleException(ioe); + return; + } + } + + @Override + public void dynamicTestRegistered(final TestIdentifier testIdentifier) { + // nothing to do + } + + @Override + public void executionSkipped(final TestIdentifier testIdentifier, final String reason) { + final long currentTime = System.currentTimeMillis(); + this.testIds.putIfAbsent(testIdentifier, new Stats(testIdentifier, currentTime)); + final Stats stats = this.testIds.get(testIdentifier); + stats.setEndedAt(currentTime); + if (testIdentifier.isTest()) { + final StringBuilder sb = new StringBuilder(); + sb.append("Test: "); + sb.append(testIdentifier.getLegacyReportingName()); + final long timeElapsed = stats.endedAt - stats.startedAt; + sb.append(" took "); + if (timeElapsed < 1000) { + sb.append(timeElapsed).append(" milli sec(s)"); + } else { + sb.append(TimeUnit.SECONDS.convert(timeElapsed, TimeUnit.MILLISECONDS)).append(" sec(s)"); + } + sb.append(" SKIPPED"); + if (reason != null && !reason.isEmpty()) { + sb.append(": ").append(reason); + } + try { + this.writer.write(sb.toString()); + this.writer.newLine(); + } catch (IOException ioe) { + handleException(ioe); + return; + } + } + // get the parent test class to which this skipped test belongs to + final Optional parentTestClass = traverseAndFindTestClass(this.testPlan, testIdentifier); + if (!parentTestClass.isPresent()) { + return; + } + final Stats parentClassStats = this.testIds.get(parentTestClass.get()); + parentClassStats.numTestsSkipped.incrementAndGet(); + } + + @Override + public void executionStarted(final TestIdentifier testIdentifier) { + final long currentTime = System.currentTimeMillis(); + // record this testidentifier's start + this.testIds.putIfAbsent(testIdentifier, new Stats(testIdentifier, currentTime)); + final Optional testClass = isTestClass(testIdentifier); + if (testClass.isPresent()) { + // if this is a test class, then print it out + try { + this.writer.write("Testcase: " + testClass.get().getClassName()); + this.writer.newLine(); + } catch (IOException ioe) { + handleException(ioe); + return; + } + } + // if this is a test (method) then increment the tests run for the test class to which + // this test belongs to + if (testIdentifier.isTest()) { + final Optional parentTestClass = traverseAndFindTestClass(this.testPlan, testIdentifier); + if (parentTestClass.isPresent()) { + final Stats parentClassStats = this.testIds.get(parentTestClass.get()); + if (parentClassStats != null) { + parentClassStats.numTestsRun.incrementAndGet(); + } + } + } + } + + @Override + public void executionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) { + final long currentTime = System.currentTimeMillis(); + final Stats stats = this.testIds.get(testIdentifier); + if (stats != null) { + stats.setEndedAt(currentTime); + } + if (testIdentifier.isTest() && shouldReportExecutionFinished(testIdentifier, testExecutionResult)) { + final StringBuilder sb = new StringBuilder(); + sb.append("Test: "); + sb.append(testIdentifier.getLegacyReportingName()); + if (stats != null) { + final long timeElapsed = stats.endedAt - stats.startedAt; + sb.append(" took "); + if (timeElapsed < 1000) { + sb.append(timeElapsed).append(" milli sec(s)"); + } else { + sb.append(TimeUnit.SECONDS.convert(timeElapsed, TimeUnit.MILLISECONDS)).append(" sec(s)"); + } + } + switch (testExecutionResult.getStatus()) { + case ABORTED: { + sb.append(" ABORTED"); + appendThrowable(sb, testExecutionResult); + break; + } + case FAILED: { + sb.append(" FAILED"); + appendThrowable(sb, testExecutionResult); + break; + } + } + try { + this.writer.write(sb.toString()); + this.writer.newLine(); + } catch (IOException ioe) { + handleException(ioe); + return; + } + } + // get the parent test class in which this test completed + final Optional parentTestClass = traverseAndFindTestClass(this.testPlan, testIdentifier); + if (!parentTestClass.isPresent()) { + return; + } + // update the stats of the parent test class + final Stats parentClassStats = this.testIds.get(parentTestClass.get()); + switch (testExecutionResult.getStatus()) { + case ABORTED: { + parentClassStats.numTestsAborted.incrementAndGet(); + break; + } + case FAILED: { + parentClassStats.numTestsFailed.incrementAndGet(); + break; + } + } + } + + @Override + public void reportingEntryPublished(final TestIdentifier testIdentifier, final ReportEntry entry) { + // nothing to do + } + + @Override + public void setDestination(final OutputStream os) { + this.outputStream = os; + try { + this.writer = new BufferedWriter(new OutputStreamWriter(this.outputStream, "UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Failed to create a writer", e); + } + } + + protected boolean shouldReportExecutionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) { + return true; + } + + private static void appendThrowable(final StringBuilder sb, TestExecutionResult result) { + if (!result.getThrowable().isPresent()) { + return; + } + final Throwable throwable = result.getThrowable().get(); + sb.append(": ").append(throwable.getMessage()); + sb.append(NEW_LINE); + final StringWriter stacktrace = new StringWriter(); + throwable.printStackTrace(new PrintWriter(stacktrace)); + sb.append(stacktrace.toString()); + } + + @Override + public void close() throws IOException { + if (this.writer != null) { + this.writer.close(); + } + super.close(); + } + + private final class Stats { + private final TestIdentifier testIdentifier; + private final AtomicLong numTestsRun = new AtomicLong(0); + private final AtomicLong numTestsFailed = new AtomicLong(0); + private final AtomicLong numTestsSkipped = new AtomicLong(0); + private final AtomicLong numTestsAborted = new AtomicLong(0); + private final long startedAt; + private long endedAt; + + private Stats(final TestIdentifier testIdentifier, final long startedAt) { + this.testIdentifier = testIdentifier; + this.startedAt = startedAt; + } + + private void setEndedAt(final long endedAt) { + this.endedAt = endedAt; + } + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java new file mode 100644 index 000000000..3a94a80d5 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java @@ -0,0 +1,363 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.util.DOMElementWriter; +import org.apache.tools.ant.util.DateUtils; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.engine.TestSource; +import org.junit.platform.engine.reporting.ReportEntry; +import org.junit.platform.engine.support.descriptor.ClassSource; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; + +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Reader; +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A {@link TestResultFormatter} which generates an XML report of the tests. The generated XML reports + * conforms to the schema of the XML that was generated by the {@code junit} task's XML + * report formatter and can be used by the {@code junitreport} task + */ +class LegacyXmlResultFormatter extends AbstractJUnitResultFormatter implements TestResultFormatter { + + private static final double ONE_SECOND = 1000.0; + + private OutputStream outputStream; + private final Map testIds = new ConcurrentHashMap<>(); + private final Map> skipped = new ConcurrentHashMap<>(); + private final Map> failed = new ConcurrentHashMap<>(); + private final Map> aborted = new ConcurrentHashMap<>(); + + private TestPlan testPlan; + private long testPlanStartedAt = -1; + private long testPlanEndedAt = -1; + private final AtomicLong numTestsRun = new AtomicLong(0); + private final AtomicLong numTestsFailed = new AtomicLong(0); + private final AtomicLong numTestsSkipped = new AtomicLong(0); + private final AtomicLong numTestsAborted = new AtomicLong(0); + + + @Override + public void testPlanExecutionStarted(final TestPlan testPlan) { + this.testPlan = testPlan; + this.testPlanStartedAt = System.currentTimeMillis(); + } + + @Override + public void testPlanExecutionFinished(final TestPlan testPlan) { + this.testPlanEndedAt = System.currentTimeMillis(); + // format and print out the result + try { + new XMLReportWriter().write(); + } catch (IOException | XMLStreamException e) { + handleException(e); + return; + } + } + + @Override + public void dynamicTestRegistered(final TestIdentifier testIdentifier) { + // nothing to do + } + + @Override + public void executionSkipped(final TestIdentifier testIdentifier, final String reason) { + final long currentTime = System.currentTimeMillis(); + this.numTestsSkipped.incrementAndGet(); + this.skipped.put(testIdentifier, Optional.ofNullable(reason)); + // a skipped test is considered started and ended now + final Stats stats = new Stats(testIdentifier, currentTime); + stats.endedAt = currentTime; + this.testIds.put(testIdentifier, stats); + } + + @Override + public void executionStarted(final TestIdentifier testIdentifier) { + final long currentTime = System.currentTimeMillis(); + this.testIds.putIfAbsent(testIdentifier, new Stats(testIdentifier, currentTime)); + if (testIdentifier.isTest()) { + this.numTestsRun.incrementAndGet(); + } + } + + @Override + public void executionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) { + final long currentTime = System.currentTimeMillis(); + final Stats stats = this.testIds.get(testIdentifier); + if (stats != null) { + stats.endedAt = currentTime; + } + switch (testExecutionResult.getStatus()) { + case SUCCESSFUL: { + break; + } + case ABORTED: { + this.numTestsAborted.incrementAndGet(); + this.aborted.put(testIdentifier, testExecutionResult.getThrowable()); + break; + } + case FAILED: { + this.numTestsFailed.incrementAndGet(); + this.failed.put(testIdentifier, testExecutionResult.getThrowable()); + break; + } + } + } + + @Override + public void reportingEntryPublished(final TestIdentifier testIdentifier, final ReportEntry entry) { + // nothing to do + } + + @Override + public void setDestination(final OutputStream os) { + this.outputStream = os; + } + + private final class Stats { + private final TestIdentifier testIdentifier; + private final long startedAt; + private long endedAt; + + private Stats(final TestIdentifier testIdentifier, final long startedAt) { + this.testIdentifier = testIdentifier; + this.startedAt = startedAt; + } + } + + private final class XMLReportWriter { + + private static final String ELEM_TESTSUITE = "testsuite"; + private static final String ELEM_PROPERTIES = "properties"; + private static final String ELEM_PROPERTY = "property"; + private static final String ELEM_TESTCASE = "testcase"; + private static final String ELEM_SKIPPED = "skipped"; + private static final String ELEM_FAILURE = "failure"; + private static final String ELEM_ABORTED = "aborted"; + private static final String ELEM_SYSTEM_OUT = "system-out"; + private static final String ELEM_SYSTEM_ERR = "system-err"; + + + private static final String ATTR_CLASSNAME = "classname"; + private static final String ATTR_NAME = "name"; + private static final String ATTR_VALUE = "value"; + private static final String ATTR_TIME = "time"; + private static final String ATTR_TIMESTAMP = "timestamp"; + private static final String ATTR_NUM_ABORTED = "aborted"; + private static final String ATTR_NUM_FAILURES = "failures"; + private static final String ATTR_NUM_TESTS = "tests"; + private static final String ATTR_NUM_SKIPPED = "skipped"; + private static final String ATTR_MESSAGE = "message"; + private static final String ATTR_TYPE = "type"; + + void write() throws XMLStreamException, IOException { + final XMLStreamWriter writer = XMLOutputFactory.newFactory().createXMLStreamWriter(outputStream, "UTF-8"); + try { + writer.writeStartDocument(); + writeTestSuite(writer); + writer.writeEndDocument(); + } finally { + writer.close(); + } + } + + void writeTestSuite(final XMLStreamWriter writer) throws XMLStreamException, IOException { + // write the testsuite element + writer.writeStartElement(ELEM_TESTSUITE); + final String testsuiteName = determineTestSuiteName(); + writer.writeAttribute(ATTR_NAME, testsuiteName); + // time taken for the tests execution + writer.writeAttribute(ATTR_TIME, String.valueOf((testPlanEndedAt - testPlanStartedAt) / ONE_SECOND)); + // add the timestamp of report generation + final String timestamp = DateUtils.format(new Date(), DateUtils.ISO8601_DATETIME_PATTERN); + writer.writeAttribute(ATTR_TIMESTAMP, timestamp); + writer.writeAttribute(ATTR_NUM_TESTS, String.valueOf(numTestsRun.longValue())); + writer.writeAttribute(ATTR_NUM_FAILURES, String.valueOf(numTestsFailed.longValue())); + writer.writeAttribute(ATTR_NUM_SKIPPED, String.valueOf(numTestsSkipped.longValue())); + writer.writeAttribute(ATTR_NUM_ABORTED, String.valueOf(numTestsAborted.longValue())); + + // write the properties + writeProperties(writer); + // write the tests + writeTestCase(writer); + writeSysOut(writer); + writeSysErr(writer); + // end the testsuite + writer.writeEndElement(); + } + + void writeProperties(final XMLStreamWriter writer) throws XMLStreamException { + final Properties properties = LegacyXmlResultFormatter.this.context.getProperties(); + if (properties == null || properties.isEmpty()) { + return; + } + writer.writeStartElement(ELEM_PROPERTIES); + for (final String prop : properties.stringPropertyNames()) { + writer.writeStartElement(ELEM_PROPERTY); + writer.writeAttribute(ATTR_NAME, prop); + writer.writeAttribute(ATTR_VALUE, properties.getProperty(prop)); + writer.writeEndElement(); + } + writer.writeEndElement(); + } + + void writeTestCase(final XMLStreamWriter writer) throws XMLStreamException { + for (final Map.Entry entry : testIds.entrySet()) { + final TestIdentifier testId = entry.getKey(); + if (!testId.isTest()) { + // only interested in test methods + continue; + } + // find the parent class of this test method + final Optional parent = testPlan.getParent(testId); + if (!parent.isPresent() || !parent.get().getSource().isPresent()) { + // we need to know the parent test class, else we aren't interested + continue; + } + final TestSource parentSource = parent.get().getSource().get(); + if (!(parentSource instanceof ClassSource)) { + continue; + } + final String classname = ((ClassSource) parentSource).getClassName(); + writer.writeStartElement(ELEM_TESTCASE); + writer.writeAttribute(ATTR_CLASSNAME, classname); + writer.writeAttribute(ATTR_NAME, testId.getDisplayName()); + final Stats stats = entry.getValue(); + writer.writeAttribute(ATTR_TIME, String.valueOf((stats.endedAt - stats.startedAt) / ONE_SECOND)); + // skipped element if the test was skipped + writeSkipped(writer, testId); + // failed element if the test failed + writeFailed(writer, testId); + // aborted element if the test was aborted + writeAborted(writer, testId); + + writer.writeEndElement(); + } + } + + private void writeSkipped(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException { + if (!skipped.containsKey(testIdentifier)) { + return; + } + writer.writeStartElement(ELEM_SKIPPED); + final Optional reason = skipped.get(testIdentifier); + if (reason.isPresent()) { + writer.writeAttribute(ATTR_MESSAGE, reason.get()); + } + writer.writeEndElement(); + } + + private void writeFailed(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException { + if (!failed.containsKey(testIdentifier)) { + return; + } + writer.writeStartElement(ELEM_FAILURE); + final Optional cause = failed.get(testIdentifier); + if (cause.isPresent()) { + final Throwable t = cause.get(); + final String message = t.getMessage(); + if (message != null && !message.trim().isEmpty()) { + writer.writeAttribute(ATTR_MESSAGE, message); + } + writer.writeAttribute(ATTR_TYPE, t.getClass().getName()); + } + writer.writeEndElement(); + } + + private void writeAborted(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException { + if (!aborted.containsKey(testIdentifier)) { + return; + } + writer.writeStartElement(ELEM_ABORTED); + final Optional cause = aborted.get(testIdentifier); + if (cause.isPresent()) { + final Throwable t = cause.get(); + final String message = t.getMessage(); + if (message != null && !message.trim().isEmpty()) { + writer.writeAttribute(ATTR_MESSAGE, message); + } + writer.writeAttribute(ATTR_TYPE, t.getClass().getName()); + } + writer.writeEndElement(); + } + + private void writeSysOut(final XMLStreamWriter writer) throws XMLStreamException, IOException { + if (!LegacyXmlResultFormatter.this.hasSysOut()) { + return; + } + writer.writeStartElement(ELEM_SYSTEM_OUT); + try (final Reader reader = LegacyXmlResultFormatter.this.getSysOutReader()) { + writeCharactersFrom(reader, writer); + } + writer.writeEndElement(); + } + + private void writeSysErr(final XMLStreamWriter writer) throws XMLStreamException, IOException { + if (!LegacyXmlResultFormatter.this.hasSysErr()) { + return; + } + writer.writeStartElement(ELEM_SYSTEM_ERR); + try (final Reader reader = LegacyXmlResultFormatter.this.getSysErrReader()) { + writeCharactersFrom(reader, writer); + } + writer.writeEndElement(); + } + + private void writeCharactersFrom(final Reader reader, final XMLStreamWriter writer) throws IOException, XMLStreamException { + final char[] chars = new char[1024]; + int numRead = -1; + while ((numRead = reader.read(chars)) != -1) { + // although it's called a DOMElementWriter, the encode method is just a + // straight forward XML util method which doesn't concern about whether + // DOM, SAX, StAX semantics. + // TODO: Perhaps make it a static method + final String encoded = new DOMElementWriter().encode(new String(chars, 0, numRead)); + writer.writeCharacters(encoded); + } + } + + private String determineTestSuiteName() { + // this is really a hack to try and match the expectations of the XML report in JUnit4.x + // world. In JUnit5, the TestPlan doesn't have a name and a TestPlan (for which this is a + // listener) can have numerous tests within it + final Set roots = testPlan.getRoots(); + if (roots.isEmpty()) { + return "UNKNOWN"; + } + for (final TestIdentifier root : roots) { + final Optional classSource = findFirstClassSource(root); + if (classSource.isPresent()) { + return classSource.get().getClassName(); + } + } + return "UNKNOWN"; + } + + private Optional findFirstClassSource(final TestIdentifier root) { + if (root.getSource().isPresent()) { + final TestSource source = root.getSource().get(); + if (source instanceof ClassSource) { + return Optional.of((ClassSource) source); + } + } + for (final TestIdentifier child : testPlan.getChildren(root)) { + final Optional classSource = findFirstClassSource(child); + if (classSource.isPresent()) { + return classSource; + } + } + return Optional.empty(); + } + } + +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/ListenerDefinition.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/ListenerDefinition.java new file mode 100644 index 000000000..afbc16161 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/ListenerDefinition.java @@ -0,0 +1,121 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.Project; +import org.apache.tools.ant.PropertyHelper; +import org.apache.tools.ant.types.EnumeratedAttribute; + +/** + * Represents the {@code <listener>} element within the {@code <junitlauncher>} + * task + */ +public class ListenerDefinition { + + private static final String LEGACY_PLAIN = "legacy-plain"; + private static final String LEGACY_BRIEF = "legacy-brief"; + private static final String LEGACY_XML = "legacy-xml"; + + private String ifProperty; + private String unlessProperty; + private String className; + private String resultFile; + private boolean sendSysOut; + private boolean sendSysErr; + + private String defaultResultFileSuffix = "txt"; + + public ListenerDefinition() { + + } + + public void setClassName(final String className) { + this.className = className; + } + + String getClassName() { + return this.className; + } + + String getIfProperty() { + return ifProperty; + } + + public void setIf(final String ifProperty) { + this.ifProperty = ifProperty; + } + + String getUnlessProperty() { + return unlessProperty; + } + + public void setUnless(final String unlessProperty) { + this.unlessProperty = unlessProperty; + } + + public void setType(final ListenerType type) { + switch (type.getValue()) { + case LEGACY_PLAIN: { + this.setClassName("org.apache.tools.ant.taskdefs.optional.junitlauncher.LegacyPlainResultFormatter"); + this.defaultResultFileSuffix = "txt"; + break; + } + case LEGACY_BRIEF: { + this.setClassName("org.apache.tools.ant.taskdefs.optional.junitlauncher.LegacyBriefResultFormatter"); + this.defaultResultFileSuffix = "txt"; + break; + } + case LEGACY_XML: { + this.setClassName("org.apache.tools.ant.taskdefs.optional.junitlauncher.LegacyXmlResultFormatter"); + this.defaultResultFileSuffix = "xml"; + break; + } + } + } + + public void setResultFile(final String filename) { + this.resultFile = filename; + } + + String requireResultFile(final TestDefinition test) { + if (this.resultFile != null) { + return this.resultFile; + } + final StringBuilder sb = new StringBuilder("TEST-"); + if (test instanceof NamedTest) { + sb.append(((NamedTest) test).getName()); + } else { + sb.append("unknown"); + } + sb.append(".").append(this.defaultResultFileSuffix); + return sb.toString(); + } + + public void setSendSysOut(final boolean sendSysOut) { + this.sendSysOut = sendSysOut; + } + + boolean shouldSendSysOut() { + return this.sendSysOut; + } + + public void setSendSysErr(final boolean sendSysErr) { + this.sendSysErr = sendSysErr; + } + + boolean shouldSendSysErr() { + return this.sendSysErr; + } + + protected boolean shouldUse(final Project project) { + final PropertyHelper propertyHelper = PropertyHelper.getPropertyHelper(project); + return propertyHelper.testIfCondition(this.ifProperty) && propertyHelper.testUnlessCondition(this.unlessProperty); + } + + public static class ListenerType extends EnumeratedAttribute { + + @Override + public String[] getValues() { + return new String[]{LEGACY_PLAIN, LEGACY_BRIEF, LEGACY_XML}; + } + } + +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/NamedTest.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/NamedTest.java new file mode 100644 index 000000000..5061c314a --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/NamedTest.java @@ -0,0 +1,14 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +/** + * A test that has a name associated with it + */ +public interface NamedTest { + + /** + * Returns the name of the test + * + * @return + */ + String getName(); +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/SingleTestClass.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/SingleTestClass.java new file mode 100644 index 000000000..f044e7e8a --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/SingleTestClass.java @@ -0,0 +1,101 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.Project; +import org.junit.Test; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.EngineFilter; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.StringTokenizer; + +/** + * Represents the single {@code test} (class) that's configured to be launched by the {@link JUnitLauncherTask} + */ +public class SingleTestClass extends TestDefinition implements NamedTest { + + private String testClass; + private Set testMethods; + + public SingleTestClass() { + + } + + public void setName(final String test) { + if (test == null || test.trim().isEmpty()) { + throw new IllegalArgumentException("Test name cannot be null or empty string"); + } + this.testClass = test; + } + + @Test + public String getName() { + return this.testClass; + } + + public void setMethods(final String methods) { + // parse the comma separated set of methods + if (methods == null || methods.trim().isEmpty()) { + this.testMethods = Collections.emptySet(); + return; + } + final StringTokenizer tokenizer = new StringTokenizer(methods, ","); + if (!tokenizer.hasMoreTokens()) { + this.testMethods = Collections.emptySet(); + return; + } + // maintain specified ordering + this.testMethods = new LinkedHashSet<>(); + while (tokenizer.hasMoreTokens()) { + final String method = tokenizer.nextToken().trim(); + if (method.isEmpty()) { + continue; + } + this.testMethods.add(method); + } + } + + boolean hasMethodsSpecified() { + return this.testMethods != null && !this.testMethods.isEmpty(); + } + + String[] getMethods() { + if (!hasMethodsSpecified()) { + return null; + } + return this.testMethods.toArray(new String[this.testMethods.size()]); + } + + @Override + List createTestRequests(final JUnitLauncherTask launcherTask) { + final Project project = launcherTask.getProject(); + if (!shouldRun(project)) { + launcherTask.log("Excluding test " + this.testClass + " since it's considered not to run " + + "in context of project " + project, Project.MSG_DEBUG); + return Collections.emptyList(); + } + final LauncherDiscoveryRequestBuilder requestBuilder = LauncherDiscoveryRequestBuilder.request(); + if (!this.hasMethodsSpecified()) { + requestBuilder.selectors(DiscoverySelectors.selectClass(this.testClass)); + } else { + // add specific methods + final String[] methods = this.getMethods(); + for (final String method : methods) { + requestBuilder.selectors(DiscoverySelectors.selectMethod(this.testClass, method)); + } + } + // add any engine filters + final String[] enginesToInclude = this.getIncludeEngines(); + if (enginesToInclude != null && enginesToInclude.length > 0) { + requestBuilder.filters(EngineFilter.includeEngines(enginesToInclude)); + } + final String[] enginesToExclude = this.getExcludeEngines(); + if (enginesToExclude != null && enginesToExclude.length > 0) { + requestBuilder.filters(EngineFilter.excludeEngines(enginesToExclude)); + } + return Collections.singletonList(new TestRequest(this, requestBuilder)); + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestClasses.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestClasses.java new file mode 100644 index 000000000..451e79f1a --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestClasses.java @@ -0,0 +1,112 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.types.Resource; +import org.apache.tools.ant.types.ResourceCollection; +import org.apache.tools.ant.types.resources.Resources; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a {@code testclasses} that's configured to be launched by the {@link JUnitLauncherTask} + */ +public class TestClasses extends TestDefinition { + + private final Resources resources = new Resources(); + + public TestClasses() { + + } + + public void add(final ResourceCollection resourceCollection) { + this.resources.add(resourceCollection); + } + + @Override + List createTestRequests(final JUnitLauncherTask launcherTask) { + final List tests = this.getTests(); + if (tests.isEmpty()) { + return Collections.emptyList(); + } + final List requests = new ArrayList<>(); + for (final SingleTestClass test : tests) { + requests.addAll(test.createTestRequests(launcherTask)); + } + return requests; + } + + private List getTests() { + if (this.resources.isEmpty()) { + return Collections.emptyList(); + } + final List tests = new ArrayList<>(); + for (final Resource resource : resources) { + if (!resource.isExists()) { + continue; + } + final String name = resource.getName(); + // we only consider .class files + if (!name.endsWith(".class")) { + continue; + } + final String className = name.substring(0, name.lastIndexOf('.')); + final BatchSourcedSingleTest test = new BatchSourcedSingleTest(className.replace(File.separatorChar, '.').replace('/', '.').replace('\\', '.')); + tests.add(test); + } + return tests; + } + + /** + * A {@link BatchSourcedSingleTest} is similar to a {@link SingleTestClass} except that + * some of the characteristics of the test (like whether to halt on failure) are borrowed + * from the {@link TestClasses batch} to which this test belongs to + */ + private final class BatchSourcedSingleTest extends SingleTestClass { + + private BatchSourcedSingleTest(final String testClassName) { + this.setName(testClassName); + } + + @Override + String getIfProperty() { + return TestClasses.this.getIfProperty(); + } + + @Override + String getUnlessProperty() { + return TestClasses.this.getUnlessProperty(); + } + + @Override + boolean isHaltOnFailure() { + return TestClasses.this.isHaltOnFailure(); + } + + @Override + String getFailureProperty() { + return TestClasses.this.getFailureProperty(); + } + + @Override + List getListeners() { + return TestClasses.this.getListeners(); + } + + @Override + String getOutputDir() { + return TestClasses.this.getOutputDir(); + } + + @Override + String[] getIncludeEngines() { + return TestClasses.this.getIncludeEngines(); + } + + @Override + String[] getExcludeEngines() { + return TestClasses.this.getExcludeEngines(); + } + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestDefinition.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestDefinition.java new file mode 100644 index 000000000..d05ae3e9f --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestDefinition.java @@ -0,0 +1,113 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.Project; +import org.apache.tools.ant.PropertyHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents the configuration details of a test that needs to be launched by the {@link JUnitLauncherTask} + */ +abstract class TestDefinition { + protected String ifProperty; + protected String unlessProperty; + protected Boolean haltOnFailure; + protected String failureProperty; + protected String outputDir; + protected String includeEngines; + protected String excludeEngines; + + protected List listeners = new ArrayList<>(); + + String getIfProperty() { + return ifProperty; + } + + public void setIf(final String ifProperty) { + this.ifProperty = ifProperty; + } + + String getUnlessProperty() { + return unlessProperty; + } + + public void setUnless(final String unlessProperty) { + this.unlessProperty = unlessProperty; + } + + boolean isHaltOnFailure() { + return this.haltOnFailure == null ? false : this.haltOnFailure; + } + + Boolean getHaltOnFailure() { + return this.haltOnFailure; + } + + public void setHaltOnFailure(final boolean haltonfailure) { + this.haltOnFailure = haltonfailure; + } + + String getFailureProperty() { + return failureProperty; + } + + public void setFailureProperty(final String failureProperty) { + this.failureProperty = failureProperty; + } + + public void addConfiguredListener(final ListenerDefinition listener) { + this.listeners.add(listener); + } + + List getListeners() { + return Collections.unmodifiableList(this.listeners); + } + + public void setOutputDir(final String dir) { + this.outputDir = dir; + } + + String getOutputDir() { + return this.outputDir; + } + + abstract List createTestRequests(final JUnitLauncherTask launcherTask); + + protected boolean shouldRun(final Project project) { + final PropertyHelper propertyHelper = PropertyHelper.getPropertyHelper(project); + return propertyHelper.testIfCondition(this.ifProperty) && propertyHelper.testUnlessCondition(this.unlessProperty); + } + + String[] getIncludeEngines() { + return includeEngines == null ? new String[0] : split(this.includeEngines, ","); + } + + public void setIncludeEngines(final String includeEngines) { + this.includeEngines = includeEngines; + } + + String[] getExcludeEngines() { + return excludeEngines == null ? new String[0] : split(this.excludeEngines, ","); + } + + public void setExcludeEngines(final String excludeEngines) { + this.excludeEngines = excludeEngines; + } + + private static String[] split(final String value, final String delimiter) { + if (value == null) { + return new String[0]; + } + final List parts = new ArrayList<>(); + for (final String part : value.split(delimiter)) { + if (part.trim().isEmpty()) { + // skip it + continue; + } + parts.add(part); + } + return parts.toArray(new String[parts.size()]); + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestExecutionContext.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestExecutionContext.java new file mode 100644 index 000000000..fd08a1baa --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestExecutionContext.java @@ -0,0 +1,28 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.Project; + +import java.util.Optional; +import java.util.Properties; + +/** + * A {@link TestExecutionContext} represents the execution context for a test + * that has been launched by the {@link JUnitLauncherTask} and provides any necessary + * contextual information about such tests. + */ +public interface TestExecutionContext { + + /** + * @return Returns the properties that were used for the execution of the test + */ + Properties getProperties(); + + + /** + * @return Returns the {@link Project} in whose context the test is being executed. + * The {@code Project} is sometimes not available, like in the case where + * the test is being run in a forked mode, in such cases this method returns + * {@link Optional#empty() an empty value} + */ + Optional getProject(); +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestRequest.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestRequest.java new file mode 100644 index 000000000..a56b3d240 --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestRequest.java @@ -0,0 +1,74 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; + +import java.io.Closeable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Holds together the necessary details about a request that will be launched by the {@link JUnitLauncherTask} + */ +final class TestRequest implements AutoCloseable { + private final TestDefinition ownerTest; + private final LauncherDiscoveryRequestBuilder discoveryRequest; + private final List closables = new ArrayList<>(); + private final List interestedInSysOut = new ArrayList<>(); + private final List interestedInSysErr = new ArrayList<>(); + + + TestRequest(final TestDefinition ownerTest, final LauncherDiscoveryRequestBuilder discoveryRequest) { + this.ownerTest = ownerTest; + this.discoveryRequest = discoveryRequest; + } + + TestDefinition getOwner() { + return ownerTest; + } + + LauncherDiscoveryRequestBuilder getDiscoveryRequest() { + return discoveryRequest; + } + + void closeUponCompletion(final Closeable closeable) { + if (closeable == null) { + return; + } + this.closables.add(closeable); + } + + void addSysOutInterest(final TestResultFormatter out) { + this.interestedInSysOut.add(out); + } + + boolean interestedInSysOut() { + return !this.interestedInSysOut.isEmpty(); + } + + Collection getSysOutInterests() { + return Collections.unmodifiableList(this.interestedInSysOut); + } + + void addSysErrInterest(final TestResultFormatter err) { + this.interestedInSysErr.add(err); + } + + boolean interestedInSysErr() { + return !this.interestedInSysErr.isEmpty(); + } + + Collection getSysErrInterests() { + return Collections.unmodifiableList(this.interestedInSysErr); + } + + public void close() throws Exception { + if (this.closables.isEmpty()) { + return; + } + for (final Closeable closeable : closables) { + closeable.close(); + } + } +} diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestResultFormatter.java b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestResultFormatter.java new file mode 100644 index 000000000..ad1e4f36a --- /dev/null +++ b/src/main/org/apache/tools/ant/taskdefs/optional/junitlauncher/TestResultFormatter.java @@ -0,0 +1,58 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.junit.platform.launcher.TestExecutionListener; + +import java.io.Closeable; +import java.io.OutputStream; + +/** + * A {@link TestExecutionListener} which lets implementing classes format and write out + * the test execution results. + */ +public interface TestResultFormatter extends TestExecutionListener, Closeable { + + /** + * This method will be invoked by the junitlauncher and will be passed the + * {@link OutputStream} to a file, to which the formatted result is expected to be written + * to. + *

+ * This method will be called once, early on, during the initialization of this + * {@link TestResultFormatter}, typically before the test execution itself has started. + *

+ * + * @param os The output stream to which to write out the result + */ + void setDestination(OutputStream os); + + /** + * This method will be invoked by the junitlauncher and will be passed a + * {@link TestExecutionContext}. This allows the {@link TestResultFormatter} to have access + * to any additional contextual information to use in the test reports. + * + * @param context The context of the execution of the test + */ + void setContext(TestExecutionContext context); + + /** + * This method will be invoked by the junitlauncher, regularly/multiple times, + * as and when any content is generated on the standard output stream during the test execution. + * This method will be only be called if the sendSysOut attribute of the listener, + * to which this {@link TestResultFormatter} is configured for, is enabled + * + * @param data The content generated on standard output stream + */ + default void sysOutAvailable(byte[] data) { + } + + /** + * This method will be invoked by the junitlauncher, regularly/multiple times, + * as and when any content is generated on the standard error stream during the test execution. + * This method will be only be called if the sendSysErr attribute of the listener, + * to which this {@link TestResultFormatter} is configured for, is enabled + * + * @param data The content generated on standard error stream + */ + default void sysErrAvailable(byte[] data) { + } + +} diff --git a/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTaskTest.java b/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTaskTest.java new file mode 100644 index 000000000..84d4875ce --- /dev/null +++ b/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/JUnitLauncherTaskTest.java @@ -0,0 +1,127 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher; + +import org.apache.tools.ant.BuildEvent; +import org.apache.tools.ant.BuildException; +import org.apache.tools.ant.BuildListener; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.ProjectHelper; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; + +/** + * Tests the {@link JUnitLauncherTask} + */ +public class JUnitLauncherTaskTest { + + private Project project; + + /** + * The JUnit setup method. + */ + @Before + public void setUp() { + File antFile = new File(System.getProperty("root"), "src/etc/testcases/taskdefs/optional/junitlauncher.xml"); + this.project = new Project(); + this.project.init(); + ProjectHelper.configureProject(project, antFile); + project.addBuildListener(new BuildListener() { + @Override + public void buildStarted(final BuildEvent event) { + + } + + @Override + public void buildFinished(final BuildEvent event) { + + } + + @Override + public void targetStarted(final BuildEvent event) { + + } + + @Override + public void targetFinished(final BuildEvent event) { + + } + + @Override + public void taskStarted(final BuildEvent event) { + + } + + @Override + public void taskFinished(final BuildEvent event) { + + } + + @Override + public void messageLogged(final BuildEvent event) { + if (event.getPriority() <= Project.MSG_INFO) { + System.out.println(event.getMessage()); + } + } + }); + } + + /** + * Tests that when a test, that's configured with {@code haltOnFailure=true}, stops the build, when the + * test fails + */ + @Test + public void testFailureStopsBuild() { + try { + project.executeTarget("test-failure-stops-build"); + Assert.fail("Test execution failure was expected to stop the build but didn't"); + } catch (BuildException be) { + // expected + } + } + + /** + * Tests that when a test, that's isn't configured with {@code haltOnFailure=true}, continues the + * build even when there are test failures + */ + @Test + public void testFailureContinuesBuild() { + project.executeTarget("test-failure-continues-build"); + } + + + /** + * Tests the execution of test that's expected to succeed + */ + @Test + public void testSuccessfulTests() { + project.executeTarget("test-success"); + } + + /** + * Tests execution of a test which is configured to execute only a particular set of test methods + */ + @Test + public void testSpecificMethodTest() { + project.executeTarget("test-one-specific-method"); + project.executeTarget("test-multiple-specific-methods"); + } + + /** + * Tests the execution of more than one {@code <test>} elements in the {@code <junitlauncher>} task + */ + @Test + public void testMultipleIndividualTests() { + project.executeTarget("test-multiple-individual"); + } + + /** + * Tests execution of tests, that have been configured using the {@code <testclasses>} nested element + * of the {@code <junitlauncher>} task + */ + @Test + public void testTestClasses() { + project.executeTarget("test-batch"); + } +} diff --git a/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/example/jupiter/JupiterSampleTest.java b/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/example/jupiter/JupiterSampleTest.java new file mode 100644 index 000000000..38b49199e --- /dev/null +++ b/src/tests/junit/org/apache/tools/ant/taskdefs/optional/junitlauncher/example/jupiter/JupiterSampleTest.java @@ -0,0 +1,50 @@ +package org.apache.tools.ant.taskdefs.optional.junitlauncher.example.jupiter; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * + */ +public class JupiterSampleTest { + + private static final String message = "The quick brown fox jumps over the lazy dog"; + + @BeforeAll + static void beforeAll() { + } + + @BeforeEach + void beforeEach() { + } + + @Test + void testSucceeds() { + System.out.println(message); + System.out.print("Hello world! "); + } + + @Test + void testFails() { + fail("intentionally failing"); + } + + @Test + @Disabled("intentionally skipped") + void testSkipped() { + } + + @AfterEach + void afterEach() { + } + + @AfterAll + static void afterAll() { + } +} diff --git a/src/tests/junit/org/example/junitlauncher/vintage/AlwaysFailingJUnit4Test.java b/src/tests/junit/org/example/junitlauncher/vintage/AlwaysFailingJUnit4Test.java new file mode 100644 index 000000000..ce4f1620a --- /dev/null +++ b/src/tests/junit/org/example/junitlauncher/vintage/AlwaysFailingJUnit4Test.java @@ -0,0 +1,16 @@ +package org.example.junitlauncher.vintage; + +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +/** + * + */ +public class AlwaysFailingJUnit4Test { + + @Test + public void testWillFail() throws Exception { + Assert.assertEquals("Values weren't equal", 3, 4); + } +} diff --git a/src/tests/junit/org/example/junitlauncher/vintage/JUnit4SampleTest.java b/src/tests/junit/org/example/junitlauncher/vintage/JUnit4SampleTest.java new file mode 100644 index 000000000..bf606a27d --- /dev/null +++ b/src/tests/junit/org/example/junitlauncher/vintage/JUnit4SampleTest.java @@ -0,0 +1,25 @@ +package org.example.junitlauncher.vintage; + +import org.junit.Assert; +import org.junit.Test; + +/** + * + */ +public class JUnit4SampleTest { + + @Test + public void testFoo() { + Assert.assertEquals(1, 1); + } + + @Test + public void testBar() throws Exception { + Assert.assertTrue(true); + } + + @Test + public void testFooBar() { + Assert.assertFalse(false); + } +}