diff --git a/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala b/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala index bfb2adacd..66527c24d 100644 --- a/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala +++ b/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala @@ -28,6 +28,7 @@ import testing.{ } import util.Logger import sbt.protocol.testing.TestResult +import sbt.internal.worker1.ForkTestMain /** * Companion object for JUnitXmlTestsListener that caches the hostname lazily. @@ -159,25 +160,36 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg } time={(e.duration() / 1000.0).toString}> { val trace: String = if (e.throwable.isDefined) { - val stringWriter = new StringWriter() - val writer = new PrintWriter(stringWriter) - e.throwable.get.printStackTrace(writer) - writer.flush() - stringWriter.toString + e.throwable.get match { + case fe: ForkTestMain.ForkError => + formatForkErrorTrace(fe) + case other => + val stringWriter = new StringWriter() + val writer = new PrintWriter(stringWriter) + other.printStackTrace(writer) + writer.flush() + stringWriter.toString + } } else { "" } + val (exType, exMessage) = e.throwable match { + case t if t.isDefined => + t.get match { + case fe: ForkTestMain.ForkError => + (fe.getOriginalName, fe.getOriginalMessage) + case other => + (other.getClass.getName, other.getMessage) + } + case _ => ("", "") + } e.status match { case TStatus.Error if (e.throwable.isDefined) => - {trace} + {trace} case TStatus.Error => case TStatus.Failure if (e.throwable.isDefined) => - {trace} + {trace} case TStatus.Failure => case TStatus.Ignored | TStatus.Skipped | TStatus.Pending => @@ -262,6 +274,36 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg writeSuite() } + /** + * Formats a ForkError stacktrace using the original exception name instead of + * the ForkError wrapper class. This fixes the JUnit XML report showing + * `sbt.internal.worker1.ForkTestMain$ForkError` instead of the actual exception type. + * See https://github.com/sbt/sbt/issues/1469 + */ + private def formatForkErrorTrace(fe: ForkTestMain.ForkError): String = { + val sb = new StringBuilder + sb.append(fe.getOriginalName) + val msg = fe.getOriginalMessage + if (msg != null) sb.append(": ").append(msg) + sb.append('\n') + for (elem <- fe.getStackTrace) + sb.append("\tat ").append(elem).append('\n') + val cause = fe.getCause + if (cause != null) { + cause match { + case feCause: ForkTestMain.ForkError => + sb.append("Caused by: ").append(formatForkErrorTrace(feCause)) + case other => + val stringWriter = new StringWriter() + val writer = new PrintWriter(stringWriter) + other.printStackTrace(writer) + writer.flush() + sb.append("Caused by: ").append(stringWriter.toString) + } + } + sb.toString + } + // Here we normalize the name to ensure that it's a nicer filename, rather than // contort the user into not using spaces. private def normalizeName(s: String) = s.replaceAll("""\s+""", "-") diff --git a/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala b/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala index 959b5f538..c775601aa 100644 --- a/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala +++ b/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala @@ -15,7 +15,9 @@ import testing.{ Event as TEvent, OptionalThrowable, Status as TStatus, TestSele import util.{ AbstractLogger, Level, ControlEvent, LogEvent } import sbt.io.IO import sbt.protocol.testing.TestResult +import sbt.internal.worker1.ForkTestMain import verify.BasicTestSuite +import scala.xml.XML object JUnitXmlTestsListenerSpec extends BasicTestSuite: @@ -88,4 +90,61 @@ object JUnitXmlTestsListenerSpec extends BasicTestSuite: val xmlFile = new File(tempDir, "TEST-TestSuite.xml") assert(xmlFile.exists(), "XML file should be created even when logger is null") + test("JUnit XML report should use original exception type for forked test failures"): + val tempDir = File.createTempFile("junit-test", "") + tempDir.delete() + tempDir.mkdirs() + try + val listener = new JUnitXmlTestsListener(tempDir, false, null) + listener.doInit() + listener.startGroup("TestSuite") + + // Simulate a forked test failure: the original NullPointerException gets + // wrapped in ForkError during serialization across the forked JVM boundary. + val originalException = new NullPointerException("something was null") + originalException.setStackTrace( + Array(new StackTraceElement("com.example.MyTest", "testFoo", "MyTest.java", 42)) + ) + val forkError = new ForkTestMain.ForkError(originalException) + + val testEvent = new TEvent: + def fullyQualifiedName = "TestSuite.testFoo" + def duration() = 100L + def status = TStatus.Failure + def fingerprint = null + def selector = new TestSelector("testFoo") + def throwable = new OptionalThrowable(forkError) + + listener.testEvent(sbt.TestEvent(Seq(testEvent))) + listener.endGroup("TestSuite", TestResult.Failed) + + val xmlFile = new File(tempDir, "TEST-TestSuite.xml") + assert(xmlFile.exists(), "XML file should be created") + val xml = XML.loadFile(xmlFile) + val failureNodes = xml \\ "failure" + assert(failureNodes.nonEmpty, "Should have a failure element") + val failureType = (failureNodes.head \ "@type").text + val failureMessage = (failureNodes.head \ "@message").text + assert( + failureType == "java.lang.NullPointerException", + s"Expected type 'java.lang.NullPointerException' but got '$failureType'" + ) + assert( + failureMessage == "something was null", + s"Expected message 'something was null' but got '$failureMessage'" + ) + val traceText = failureNodes.head.text + assert( + traceText.contains("java.lang.NullPointerException"), + s"Stacktrace should contain original exception name, got: $traceText" + ) + assert( + !traceText.contains("ForkError"), + s"Stacktrace should not contain ForkError, got: $traceText" + ) + finally + if tempDir.exists() then + tempDir.listFiles().foreach(_.delete()) + tempDir.delete() + end JUnitXmlTestsListenerSpec diff --git a/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java b/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java index ce18a426f..c4a6fd102 100644 --- a/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java +++ b/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java @@ -158,6 +158,16 @@ public class ForkTestMain { return originalName + ": " + originalMessage; } + /** Returns the fully qualified class name of the original exception. */ + public String getOriginalName() { + return originalName; + } + + /** Returns the original exception message (without the class name prefix). */ + public String getOriginalMessage() { + return originalMessage; + } + public Exception getCause() { return cause1; }