This commit is contained in:
tc
2026-07-15 01:21:15 +02:00
committed by GitHub
3 changed files with 116 additions and 12 deletions
@@ -28,6 +28,7 @@ import testing.{
} }
import util.Logger import util.Logger
import sbt.protocol.testing.TestResult import sbt.protocol.testing.TestResult
import sbt.internal.worker1.ForkTestMain
/** /**
* Companion object for JUnitXmlTestsListener that caches the hostname lazily. * 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}> } time={(e.duration() / 1000.0).toString}>
{ {
val trace: String = if (e.throwable.isDefined) { val trace: String = if (e.throwable.isDefined) {
val stringWriter = new StringWriter() e.throwable.get match {
val writer = new PrintWriter(stringWriter) case fe: ForkTestMain.ForkError =>
e.throwable.get.printStackTrace(writer) formatForkErrorTrace(fe)
writer.flush() case other =>
stringWriter.toString val stringWriter = new StringWriter()
val writer = new PrintWriter(stringWriter)
other.printStackTrace(writer)
writer.flush()
stringWriter.toString
}
} else { } 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 { e.status match {
case TStatus.Error if (e.throwable.isDefined) => case TStatus.Error if (e.throwable.isDefined) =>
<error message={e.throwable.get.getMessage} type={ <error message={exMessage} type={exType}>{trace}</error>
e.throwable.get.getClass.getName
}>{trace}</error>
case TStatus.Error => case TStatus.Error =>
<error message={"No Exception or message provided"}/> <error message={"No Exception or message provided"}/>
case TStatus.Failure if (e.throwable.isDefined) => case TStatus.Failure if (e.throwable.isDefined) =>
<failure message={e.throwable.get.getMessage} type={ <failure message={exMessage} type={exType}>{trace}</failure>
e.throwable.get.getClass.getName
}>{trace}</failure>
case TStatus.Failure => case TStatus.Failure =>
<failure message={"No Exception or message provided"}/> <failure message={"No Exception or message provided"}/>
case TStatus.Ignored | TStatus.Skipped | TStatus.Pending => case TStatus.Ignored | TStatus.Skipped | TStatus.Pending =>
@@ -262,6 +274,36 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
writeSuite() 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 // Here we normalize the name to ensure that it's a nicer filename, rather than
// contort the user into not using spaces. // contort the user into not using spaces.
private def normalizeName(s: String) = s.replaceAll("""\s+""", "-") private def normalizeName(s: String) = s.replaceAll("""\s+""", "-")
@@ -15,7 +15,9 @@ import testing.{ Event as TEvent, OptionalThrowable, Status as TStatus, TestSele
import util.{ AbstractLogger, Level, ControlEvent, LogEvent } import util.{ AbstractLogger, Level, ControlEvent, LogEvent }
import sbt.io.IO import sbt.io.IO
import sbt.protocol.testing.TestResult import sbt.protocol.testing.TestResult
import sbt.internal.worker1.ForkTestMain
import verify.BasicTestSuite import verify.BasicTestSuite
import scala.xml.XML
object JUnitXmlTestsListenerSpec extends BasicTestSuite: object JUnitXmlTestsListenerSpec extends BasicTestSuite:
@@ -88,4 +90,54 @@ object JUnitXmlTestsListenerSpec extends BasicTestSuite:
val xmlFile = new File(tempDir, "TEST-TestSuite.xml") val xmlFile = new File(tempDir, "TEST-TestSuite.xml")
assert(xmlFile.exists(), "XML file should be created even when logger is null") 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"):
IO.withTemporaryDirectory: tempDir =>
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"
)
end JUnitXmlTestsListenerSpec end JUnitXmlTestsListenerSpec
@@ -167,7 +167,7 @@ public class ForkTestMain {
private final String originalName; private final String originalName;
private ForkError cause1; private ForkError cause1;
ForkError(final Throwable t) { public ForkError(final Throwable t) {
originalMessage = t.getMessage(); originalMessage = t.getMessage();
originalName = t.getClass().getName(); originalName = t.getClass().getName();
setStackTrace(t.getStackTrace()); setStackTrace(t.getStackTrace());
@@ -178,6 +178,16 @@ public class ForkTestMain {
return originalName + ": " + originalMessage; 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() { public Exception getCause() {
return cause1; return cause1;
} }