diff --git a/main/src/main/scala/sbt/plugins/JUnitXmlReportPlugin.scala b/main/src/main/scala/sbt/plugins/JUnitXmlReportPlugin.scala index d70115072..06b242057 100644 --- a/main/src/main/scala/sbt/plugins/JUnitXmlReportPlugin.scala +++ b/main/src/main/scala/sbt/plugins/JUnitXmlReportPlugin.scala @@ -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 in JUnit XML reports." + ).withRank(BSetting) + val testReportXmlCaptureStdErr = + settingKey[Boolean]( + "If true, capture test framework error output into 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 ) } ) diff --git a/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala b/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala index bfb2adacd..b2f1aa142 100644 --- a/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala +++ b/testing/src/main/scala/sbt/JUnitXmlTestsListener.scala @@ -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 } - - + {scala.xml.PCData(stdOutBuffer.toString)} + {scala.xml.PCData(stdErrBuffer.toString)} 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, () => ()) + } + } } diff --git a/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala b/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala index 98074f666..4db4f1813 100644 --- a/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala +++ b/testing/src/test/scala/sbt/JUnitXmlTestsListenerSpec.scala @@ -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