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;
}