This is an automated email from the ASF dual-hosted git repository. azotcsit pushed a commit to branch cassandra-16630_junit5 in repository https://gitbox.apache.org/repos/asf/cassandra.git
commit 56a32b87b9d91b50d3a228dff6369ea751714435 Author: Aleksei Zotov <[email protected]> AuthorDate: Sun Nov 14 20:07:36 2021 +0400 CASSANDRA-16630. Copied Ant JUnit classes. --- .../AbstractJUnitResultFormatter.java | 308 +++++++++++++++ .../junitlauncher/LegacyBriefResultFormatter.java | 34 ++ .../junitlauncher/LegacyPlainResultFormatter.java | 290 ++++++++++++++ .../junitlauncher/LegacyXmlResultFormatter.java | 424 +++++++++++++++++++++ 4 files changed, 1056 insertions(+) diff --git a/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java new file mode 100644 index 0000000..221aadb --- /dev/null +++ b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/AbstractJUnitResultFormatter.java @@ -0,0 +1,308 @@ +/* + * 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 + * + * https://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.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.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +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 TestExecutionContext context; + + private SysOutErrContentStore sysOutStore; + private SysOutErrContentStore sysErrStore; + + @Override + public void sysOutAvailable(final byte[] data) { + if (this.sysOutStore == null) { + this.sysOutStore = new SysOutErrContentStore(context, true); + } + try { + this.sysOutStore.store(data); + } catch (IOException e) { + handleException(e); + } + } + + @Override + public void sysErrAvailable(final byte[] data) { + if (this.sysErrStore == null) { + this.sysErrStore = new SysOutErrContentStore(context, false); + } + try { + this.sysErrStore.store(data); + } catch (IOException e) { + handleException(e); + } + } + + @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<TestIdentifier> traverseAndFindTestClass(final TestPlan testPlan, final TestIdentifier testIdentifier) { + if (isTestClass(testIdentifier).isPresent()) { + return Optional.of(testIdentifier); + } + final Optional<TestIdentifier> parent = testPlan.getParent(testIdentifier); + return parent.isPresent() ? traverseAndFindTestClass(testPlan, parent.get()) : Optional.empty(); + } + + static Optional<ClassSource> isTestClass(final TestIdentifier testIdentifier) { + if (testIdentifier == null) { + return Optional.empty(); + } + final Optional<TestSource> 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 TestExecutionContext context; + private final String tmpFileSuffix; + private ByteBuffer inMemoryStore = ByteBuffer.allocate(DEFAULT_CAPACITY_IN_BYTES); + private boolean usingFileStore = false; + private Path filePath; + private OutputStream fileOutputStream; + + private SysOutErrContentStore(final TestExecutionContext context, final boolean isSysOut) { + this.context = context; + 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 OutputStream createFileStore() throws IOException { + this.filePath = FileUtils.getFileUtils() + .createTempFile(context.getProject().orElse(null), null, this.tmpFileSuffix, null, true, true) + .toPath(); + return Files.newOutputStream(this.filePath); + } + + /* + * 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; + } + return this.usingFileStore && this.filePath != null; + } + + @Override + public void close() throws IOException { + this.inMemoryStore = null; + FileUtils.close(this.fileOutputStream); + FileUtils.delete(this.filePath.toFile()); + } + } +} diff --git a/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java new file mode 100644 index 0000000..7debbf0 --- /dev/null +++ b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyBriefResultFormatter.java @@ -0,0 +1,34 @@ +/* + * 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 + * + * https://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.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/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java new file mode 100644 index 0000000..7583d78 --- /dev/null +++ b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyPlainResultFormatter.java @@ -0,0 +1,290 @@ +/* + * 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 + * + * https://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.nio.charset.StandardCharsets; +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<TestIdentifier, Stats> testIds = new ConcurrentHashMap<>(); + private TestPlan testPlan; + private BufferedWriter writer; + private boolean useLegacyReportingName = true; + + @Override + public void testPlanExecutionStarted(final TestPlan testPlan) { + this.testPlan = testPlan; + } + + @Override + public void testPlanExecutionFinished(final TestPlan testPlan) { + for (final Map.Entry<TestIdentifier, Stats> 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()); + sb.append(", Time elapsed: "); + stats.appendElapsed(sb); + 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); + } + } + + @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(this.useLegacyReportingName ? testIdentifier.getLegacyReportingName() : testIdentifier.getDisplayName()); + sb.append(" took "); + stats.appendElapsed(sb); + 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<TestIdentifier> 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<ClassSource> 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<TestIdentifier> parentTestClass = traverseAndFindTestClass(this.testPlan, testIdentifier); + if (parentTestClass.isPresent()) { + final Stats parentClassStats = this.testIds.get(parentTestClass.get()); + if (parentClassStats != null) { + parentClassStats.numTestsRun.incrementAndGet(); + } + } + } + } + + @SuppressWarnings("incomplete-switch") + @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(this.useLegacyReportingName ? testIdentifier.getLegacyReportingName() : testIdentifier.getDisplayName()); + if (stats != null) { + sb.append(" took "); + stats.appendElapsed(sb); + } + 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<TestIdentifier> 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; + this.writer = new BufferedWriter(new OutputStreamWriter(this.outputStream, StandardCharsets.UTF_8)); + } + + @Override + public void setUseLegacyReportingName(final boolean useLegacyReportingName) { + this.useLegacyReportingName = useLegacyReportingName; + } + + 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(String.format(": %s%n", throwable.getMessage())); + 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 { + @SuppressWarnings("unused") + 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; + } + + private void appendElapsed(StringBuilder sb) { + final long timeElapsed = endedAt - startedAt; + if (timeElapsed < 1000) { + sb.append(timeElapsed).append(" milli sec(s)"); + } else { + sb.append(TimeUnit.SECONDS.convert(timeElapsed, TimeUnit.MILLISECONDS)).append(" sec(s)"); + } + } + } +} diff --git a/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java new file mode 100644 index 0000000..bb9a963 --- /dev/null +++ b/test/unit/org/apache/tools/ant/taskdefs/optional/junitlauncher/LegacyXmlResultFormatter.java @@ -0,0 +1,424 @@ +/* + * 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 + * + * https://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.apache.tools.ant.util.DOMElementWriter; +import org.apache.tools.ant.util.DateUtils; +import org.apache.tools.ant.util.StringUtils; +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<TestIdentifier, Stats> testIds = new ConcurrentHashMap<>(); + private final Map<TestIdentifier, Optional<String>> skipped = new ConcurrentHashMap<>(); + private final Map<TestIdentifier, Optional<Throwable>> failed = new ConcurrentHashMap<>(); + private final Map<TestIdentifier, Optional<Throwable>> 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); + private boolean useLegacyReportingName = true; + + + @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); + } + } + + @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; + } + + @Override + public void setUseLegacyReportingName(final boolean useLegacyReportingName) { + this.useLegacyReportingName = useLegacyReportingName; + } + + private final class Stats { + @SuppressWarnings("unused") + 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(); + writeAttribute(writer, ATTR_NAME, testsuiteName); + // time taken for the tests execution + writeAttribute(writer, 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); + writeAttribute(writer, ATTR_TIMESTAMP, timestamp); + writeAttribute(writer, ATTR_NUM_TESTS, String.valueOf(numTestsRun.longValue())); + writeAttribute(writer, ATTR_NUM_FAILURES, String.valueOf(numTestsFailed.longValue())); + writeAttribute(writer, ATTR_NUM_SKIPPED, String.valueOf(numTestsSkipped.longValue())); + writeAttribute(writer, 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); + writeAttribute(writer, ATTR_NAME, prop); + writeAttribute(writer, ATTR_VALUE, properties.getProperty(prop)); + writer.writeEndElement(); + } + writer.writeEndElement(); + } + + void writeTestCase(final XMLStreamWriter writer) throws XMLStreamException { + for (final Map.Entry<TestIdentifier, Stats> entry : testIds.entrySet()) { + final TestIdentifier testId = entry.getKey(); + if (!testId.isTest() && !failed.containsKey(testId)) { + // only interested in test methods unless there was a failure, + // in which case we want the exception reported + // (https://bz.apache.org/bugzilla/show_bug.cgi?id=63850) + continue; + } + // find the associated class of this test + final Optional<ClassSource> parentClassSource; + if (testId.isTest()) { + parentClassSource = findFirstParentClassSource(testId); + } + else { + parentClassSource = findFirstClassSource(testId); + } + if (!parentClassSource.isPresent()) { + continue; + } + final String classname = (parentClassSource.get()).getClassName(); + writer.writeStartElement(ELEM_TESTCASE); + writeAttribute(writer, ATTR_CLASSNAME, classname); + writeAttribute(writer, ATTR_NAME, useLegacyReportingName ? testId.getLegacyReportingName() + : testId.getDisplayName()); + final Stats stats = entry.getValue(); + writeAttribute(writer, 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<String> reason = skipped.get(testIdentifier); + if (reason.isPresent()) { + writeAttribute(writer, 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<Throwable> cause = failed.get(testIdentifier); + if (cause.isPresent()) { + final Throwable t = cause.get(); + final String message = t.getMessage(); + if (message != null && !message.trim().isEmpty()) { + writeAttribute(writer, ATTR_MESSAGE, message); + } + writeAttribute(writer, ATTR_TYPE, t.getClass().getName()); + // write out the stacktrace + writer.writeCData(StringUtils.getStackTrace(t)); + } + writer.writeEndElement(); + } + + private void writeAborted(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException { + if (!aborted.containsKey(testIdentifier)) { + return; + } + writer.writeStartElement(ELEM_ABORTED); + final Optional<Throwable> cause = aborted.get(testIdentifier); + if (cause.isPresent()) { + final Throwable t = cause.get(); + final String message = t.getMessage(); + if (message != null && !message.trim().isEmpty()) { + writeAttribute(writer, ATTR_MESSAGE, message); + } + writeAttribute(writer, ATTR_TYPE, t.getClass().getName()); + // write out the stacktrace + writer.writeCData(StringUtils.getStackTrace(t)); + } + 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) { + writer.writeCharacters(encode(new String(chars, 0, numRead))); + } + } + + private void writeAttribute(final XMLStreamWriter writer, final String name, final String value) + throws XMLStreamException { + writer.writeAttribute(name, encode(value)); + } + + private String encode(final String s) { + boolean changed = false; + final StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + if (!DOMElementWriter.isLegalXmlCharacter(c)) { + changed = true; + sb.append("&#").append((int) c).append(';'); + } else { + sb.append(c); + } + } + return changed ? sb.toString() : s; + } + + 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<TestIdentifier> roots = testPlan.getRoots(); + if (roots.isEmpty()) { + return "UNKNOWN"; + } + for (final TestIdentifier root : roots) { + final Optional<ClassSource> classSource = findFirstClassSource(root); + if (classSource.isPresent()) { + return classSource.get().getClassName(); + } + } + return "UNKNOWN"; + } + + private Optional<ClassSource> 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> classSource = findFirstClassSource(child); + if (classSource.isPresent()) { + return classSource; + } + } + return Optional.empty(); + } + + private Optional<ClassSource> findFirstParentClassSource(final TestIdentifier testId) { + final Optional<TestIdentifier> parent = testPlan.getParent(testId); + if (!parent.isPresent()) { + return Optional.empty(); + } + if (!parent.get().getSource().isPresent()) { + // the source of the parent is unknown, so we move up the + // hierarchy and try and find a class source + return findFirstParentClassSource(parent.get()); + } + final TestSource parentSource = parent.get().getSource().get(); + return parentSource instanceof ClassSource ? Optional.of((ClassSource) parentSource) + : findFirstParentClassSource(parent.get()); + } + } + +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
