From a1db6902aa0de6359d75fcb4fac571c36fbd3bf2 Mon Sep 17 00:00:00 2001 From: volcano303 Date: Fri, 10 Apr 2026 17:23:22 +0000 Subject: [PATCH] Fix JUnit XML report showing ForkError instead of original exception type in forked mode (#1469) --- .../scala/sbt/JUnitXmlTestsListener.scala | 64 +++++++++++++++---- .../scala/sbt/JUnitXmlTestsListenerSpec.scala | 59 +++++++++++++++++ .../sbt/internal/worker1/ForkTestMain.java | 10 +++ 3 files changed, 122 insertions(+), 11 deletions(-) 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 98074f666..4f5a2cb95 100644 --- a/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala +++ b/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala @@ -14,7 +14,9 @@ import java.util.concurrent.atomic.AtomicReference import testing.{ Event as TEvent, OptionalThrowable, Status as TStatus, TestSelector } import util.{ AbstractLogger, Level, ControlEvent, LogEvent } import sbt.protocol.testing.TestResult +import sbt.internal.worker1.ForkTestMain import verify.BasicTestSuite +import scala.xml.XML object JUnitXmlTestsListenerSpec extends BasicTestSuite: @@ -102,4 +104,61 @@ object JUnitXmlTestsListenerSpec extends BasicTestSuite: tempDir.listFiles().foreach(_.delete()) tempDir.delete() + 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; }