[2.x] feat: Capture test output into JUnit XML system-out/system-err (fixes #6537)

This commit is contained in:
Jakearmstrong59 2026-04-10 21:55:25 +00:00
parent e0bdb531f7
commit 2bc12083cf
3 changed files with 210 additions and 8 deletions

View File

@ -38,14 +38,26 @@ object JUnitXmlReportPlugin extends AutoPlugin {
object autoImport {
val testReportsDirectory =
settingKey[File]("Directory for outputting junit test reports.").withRank(AMinusSetting)
val testReportXmlCaptureStdOut =
settingKey[Boolean](
"If true, capture test framework log output into <system-out> in JUnit XML reports."
).withRank(BSetting)
val testReportXmlCaptureStdErr =
settingKey[Boolean](
"If true, capture test framework error output into <system-err> in JUnit XML reports."
).withRank(BSetting)
lazy val testReportSettings: Seq[Setting[?]] = Seq(
testReportsDirectory := target.value / (prefix(configuration.value.name) + "reports"),
testReportXmlCaptureStdOut := false,
testReportXmlCaptureStdErr := false,
testListeners += Def.uncached {
JUnitXmlTestsListener(
testReportsDirectory.value,
SysProp.legacyTestReport,
streams.value.log
streams.value.log,
testReportXmlCaptureStdOut.value,
testReportXmlCaptureStdErr.value
)
}
)

View File

@ -21,6 +21,7 @@ import scala.util.Properties
import scala.xml.{ Elem, Node as XNode, XML }
import testing.{
Event as TEvent,
Logger as TLogger,
NestedTestSelector,
Status as TStatus,
OptionalThrowable,
@ -61,15 +62,32 @@ object JUnitXmlTestsListener {
* @param targetDir
* directory in which test reports are generated
*/
class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logger: Logger)
extends TestsListener {
class JUnitXmlTestsListener(
val targetDir: File,
legacyTestReport: Boolean,
logger: Logger,
captureStdOut: Boolean,
captureStdErr: Boolean
) extends TestsListener {
// These constructors are for binary compatibility with older versions of sbt
// Use old hard-coded behaviour for constructing `targetDir` from `outputDir`
def this(
outputDir: String,
legacyTestReport: Boolean,
logger: Logger,
captureStdOut: Boolean,
captureStdErr: Boolean
) =
this(new File(outputDir, "test-reports"), legacyTestReport, logger, captureStdOut, captureStdErr)
def this(targetDir: File, legacyTestReport: Boolean, logger: Logger) =
this(targetDir, legacyTestReport, logger, false, false)
def this(outputDir: String, legacyTestReport: Boolean, logger: Logger) =
this(new File(outputDir, "test-reports"), legacyTestReport, logger)
this(new File(outputDir, "test-reports"), legacyTestReport, logger, false, false)
def this(outputDir: String, logger: Logger) = this(outputDir, false, logger)
def this(outputDir: String) = this(outputDir, false, null)
private val captureEnabled: Boolean = captureStdOut || captureStdErr
/** Current hostname so we know which machine executed the tests */
lazy val hostname: String = {
val name = JUnitXmlTestsListener.hostname
@ -108,6 +126,8 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
def this(name: String) = this(name, LocalDateTime.now())
val events: ListBuffer[TEvent] = new ListBuffer()
val stdOutBuffer: StringBuilder = new StringBuilder()
val stdErrBuffer: StringBuilder = new StringBuilder()
/** Adds one test result to this suite. */
def addEvent(e: TEvent): ListBuffer[TEvent] = events += e
@ -188,8 +208,8 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
</testcase>
}
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
<system-out>{scala.xml.PCData(stdOutBuffer.toString)}</system-out>
<system-err>{scala.xml.PCData(stdErrBuffer.toString)}</system-err>
</testsuite>
result
@ -289,6 +309,40 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
/** Does nothing, as we write each file after a suite is done. */
override def doComplete(finalResult: TestResult): Unit = {}
/** Returns None */
override def contentLogger(test: TestDefinition): Option[ContentLogger] = None
/**
* Returns a ContentLogger that captures test output into the suite's
* stdout/stderr buffers when capture is enabled via testReportXmlCaptureStdOut
* or testReportXmlCaptureStdErr settings.
*/
override def contentLogger(test: TestDefinition): Option[ContentLogger] = {
if (!captureEnabled) return None
testSuite.get().map { suite =>
val tLogger = new TLogger {
def error(s: String): Unit =
if (captureStdErr) suite.stdErrBuffer.synchronized {
suite.stdErrBuffer.append(s).append('\n')
}
def warn(s: String): Unit =
if (captureStdErr) suite.stdErrBuffer.synchronized {
suite.stdErrBuffer.append(s).append('\n')
}
def info(s: String): Unit =
if (captureStdOut) suite.stdOutBuffer.synchronized {
suite.stdOutBuffer.append(s).append('\n')
}
def debug(s: String): Unit =
if (captureStdOut) suite.stdOutBuffer.synchronized {
suite.stdOutBuffer.append(s).append('\n')
}
def trace(t: Throwable): Unit =
if (captureStdErr) suite.stdErrBuffer.synchronized {
val sw = new StringWriter()
t.printStackTrace(new PrintWriter(sw))
suite.stdErrBuffer.append(sw.toString)
}
def ansiCodesSupported(): Boolean = false
}
new ContentLogger(tLogger, () => ())
}
}
}

View File

@ -102,4 +102,140 @@ object JUnitXmlTestsListenerSpec extends BasicTestSuite:
tempDir.listFiles().foreach(_.delete())
tempDir.delete()
test("JUnitXmlTestsListener should capture stdout and stderr when enabled"):
val tempDir = File.createTempFile("junit-test", "")
tempDir.delete()
tempDir.mkdirs()
try
val listener = new JUnitXmlTestsListener(tempDir, false, null, true, true)
listener.doInit()
listener.startGroup("CaptureTestSuite")
// Get the content logger and write to it
val testDef = new TestDefinition(
"CaptureTestSuite.testCapture",
null,
false,
Array(new TestSelector("testCapture"))
)
val contentLoggerOpt = listener.contentLogger(testDef)
assert(contentLoggerOpt.isDefined, "contentLogger should return Some when capture is enabled")
val cl = contentLoggerOpt.get
cl.log.info("hello stdout")
cl.log.debug("debug stdout")
cl.log.error("hello stderr")
cl.log.warn("warn stderr")
cl.flush()
val testEvent = new TEvent:
def fullyQualifiedName = "CaptureTestSuite.testCapture"
def duration() = 50L
def status = TStatus.Success
def fingerprint = null
def selector = new TestSelector("testCapture")
def throwable = new OptionalThrowable()
listener.testEvent(sbt.TestEvent(Seq(testEvent)))
listener.endGroup("CaptureTestSuite", TestResult.Passed)
// Read and verify the XML
val xmlFile = new File(tempDir, "TEST-CaptureTestSuite.xml")
assert(xmlFile.exists(), "XML file should be created")
val xml = scala.xml.XML.loadFile(xmlFile)
val sysOut = (xml \ "system-out").text
val sysErr = (xml \ "system-err").text
assert(sysOut.contains("hello stdout"), s"system-out should contain 'hello stdout', got: $sysOut")
assert(sysOut.contains("debug stdout"), s"system-out should contain 'debug stdout', got: $sysOut")
assert(sysErr.contains("hello stderr"), s"system-err should contain 'hello stderr', got: $sysErr")
assert(sysErr.contains("warn stderr"), s"system-err should contain 'warn stderr', got: $sysErr")
finally
if tempDir.exists() then
tempDir.listFiles().foreach(_.delete())
tempDir.delete()
test("JUnitXmlTestsListener should have empty system-out/err when capture is disabled"):
val tempDir = File.createTempFile("junit-test", "")
tempDir.delete()
tempDir.mkdirs()
try
val listener = new JUnitXmlTestsListener(tempDir, false, null, false, false)
listener.doInit()
listener.startGroup("NoCaptureTestSuite")
val testDef = new TestDefinition(
"NoCaptureTestSuite.testNoCapture",
null,
false,
Array(new TestSelector("testNoCapture"))
)
val contentLoggerOpt = listener.contentLogger(testDef)
assert(contentLoggerOpt.isEmpty, "contentLogger should return None when capture is disabled")
val testEvent = new TEvent:
def fullyQualifiedName = "NoCaptureTestSuite.testNoCapture"
def duration() = 50L
def status = TStatus.Success
def fingerprint = null
def selector = new TestSelector("testNoCapture")
def throwable = new OptionalThrowable()
listener.testEvent(sbt.TestEvent(Seq(testEvent)))
listener.endGroup("NoCaptureTestSuite", TestResult.Passed)
val xmlFile = new File(tempDir, "TEST-NoCaptureTestSuite.xml")
assert(xmlFile.exists(), "XML file should be created")
val xml = scala.xml.XML.loadFile(xmlFile)
val sysOut = (xml \ "system-out").text
val sysErr = (xml \ "system-err").text
assert(sysOut.isEmpty, s"system-out should be empty, got: $sysOut")
assert(sysErr.isEmpty, s"system-err should be empty, got: $sysErr")
finally
if tempDir.exists() then
tempDir.listFiles().foreach(_.delete())
tempDir.delete()
test("JUnitXmlTestsListener should capture only stdout when only captureStdOut is enabled"):
val tempDir = File.createTempFile("junit-test", "")
tempDir.delete()
tempDir.mkdirs()
try
val listener = new JUnitXmlTestsListener(tempDir, false, null, true, false)
listener.doInit()
listener.startGroup("StdOutOnlySuite")
val testDef = new TestDefinition(
"StdOutOnlySuite.test",
null,
false,
Array(new TestSelector("test"))
)
val contentLoggerOpt = listener.contentLogger(testDef)
assert(contentLoggerOpt.isDefined, "contentLogger should return Some when captureStdOut is enabled")
val cl = contentLoggerOpt.get
cl.log.info("info message")
cl.log.error("error message")
cl.flush()
val testEvent = new TEvent:
def fullyQualifiedName = "StdOutOnlySuite.test"
def duration() = 50L
def status = TStatus.Success
def fingerprint = null
def selector = new TestSelector("test")
def throwable = new OptionalThrowable()
listener.testEvent(sbt.TestEvent(Seq(testEvent)))
listener.endGroup("StdOutOnlySuite", TestResult.Passed)
val xmlFile = new File(tempDir, "TEST-StdOutOnlySuite.xml")
val xml = scala.xml.XML.loadFile(xmlFile)
val sysOut = (xml \ "system-out").text
val sysErr = (xml \ "system-err").text
assert(sysOut.contains("info message"), s"system-out should contain 'info message', got: $sysOut")
assert(!sysErr.contains("error message"), s"system-err should be empty when captureStdErr is false, got: $sysErr")
finally
if tempDir.exists() then
tempDir.listFiles().foreach(_.delete())
tempDir.delete()
end JUnitXmlTestsListenerSpec