This commit is contained in:
volcano303 2026-04-13 22:01:35 -04:00 committed by GitHub
commit 5ac55877ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 11 deletions

View File

@ -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) =>
<error message={e.throwable.get.getMessage} type={
e.throwable.get.getClass.getName
}>{trace}</error>
<error message={exMessage} type={exType}>{trace}</error>
case TStatus.Error =>
<error message={"No Exception or message provided"}/>
case TStatus.Failure if (e.throwable.isDefined) =>
<failure message={e.throwable.get.getMessage} type={
e.throwable.get.getClass.getName
}>{trace}</failure>
<failure message={exMessage} type={exType}>{trace}</failure>
case TStatus.Failure =>
<failure message={"No Exception or message provided"}/>
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+""", "-")

View File

@ -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

View File

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