diff --git a/build.sc b/build.sc
index 214308a85da..a897484a7f4 100644
--- a/build.sc
+++ b/build.sc
@@ -176,6 +176,7 @@ object Deps {
ivy"org.scoverage::scalac-scoverage-serializer:${scoverage2Version}"
val scalaparse = ivy"com.lihaoyi::scalaparse:${fastparse.version}"
val scalatags = ivy"com.lihaoyi::scalatags:0.12.0"
+ def scalaXml = ivy"org.scala-lang.modules::scala-xml:2.2.0"
// keep in sync with doc/antora/antory.yml
val semanticDBscala = ivy"org.scalameta:::semanticdb-scalac:4.9.3"
val semanticDbJava = ivy"com.sourcegraph:semanticdb-java:0.9.9"
@@ -728,7 +729,7 @@ def formatDep(dep: Dep) = {
object scalalib extends MillStableScalaModule {
def moduleDeps = Seq(main, scalalib.api, testrunner)
- def ivyDeps = Agg(Deps.scalafmtDynamic)
+ def ivyDeps = Agg(Deps.scalafmtDynamic, Deps.scalaXml)
def testIvyDeps = super.testIvyDeps() ++ Agg(Deps.scalaCheck)
def testTransitiveDeps = super.testTransitiveDeps() ++ Seq(worker.testDep())
diff --git a/scalalib/src/mill/scalalib/TestModule.scala b/scalalib/src/mill/scalalib/TestModule.scala
index 12944e7a3f3..f3ccee241d5 100644
--- a/scalalib/src/mill/scalalib/TestModule.scala
+++ b/scalalib/src/mill/scalalib/TestModule.scala
@@ -6,6 +6,7 @@ import mill.api.{Ctx, PathRef, Result}
import mill.util.Jvm
import mill.scalalib.bsp.{BspBuildTarget, BspModule}
import mill.testrunner.{Framework, TestArgs, TestResult, TestRunner}
+import sbt.testing.Status
trait TestModule
extends TestModule.JavaModuleBase
@@ -93,6 +94,12 @@ trait TestModule
*/
def testUseArgsFile: T[Boolean] = T { runUseArgsFile() || scala.util.Properties.isWin }
+ /**
+ * Sets the file name for the generated JUnit-compatible test report.
+ * If None is set, no file will be generated.
+ */
+ def testReportXml: T[Option[String]] = T(Some("test-report.xml"))
+
/**
* The actual task shared by `test`-tasks that runs test in a forked JVM.
*/
@@ -101,6 +108,8 @@ trait TestModule
globSelectors: Task[Seq[String]]
): Task[(String, Seq[TestResult])] =
T.task {
+ testReportXml().foreach(file => os.remove(T.ctx().dest / file))
+
val outputPath = T.dest / "out.json"
val useArgsFile = testUseArgsFile()
@@ -160,6 +169,7 @@ trait TestModule
val jsonOutput = ujson.read(outputPath.toIO)
val (doneMsg, results) =
upickle.default.read[(String, Seq[TestResult])](jsonOutput)
+ testReportXml().foreach(file => TestModule.genTestXmlReport(results, T.ctx().dest / file))
TestModule.handleResults(doneMsg, results, Some(T.ctx()))
} catch {
case e: Throwable =>
@@ -321,4 +331,69 @@ object TestModule {
trait ScalaModuleBase extends mill.Module {
def scalacOptions: T[Seq[String]] = Seq.empty[String]
}
+
+ case class TestResultExtra(suiteName: String, testName: String, result: TestResult)
+
+ def genTestXmlReport(results0: Seq[TestResult], out: os.Path): Unit = {
+ val results = results0.map { r =>
+ val (suiteName, testName) = splitFullyQualifiedName(r.selector)
+ TestResultExtra(suiteName, testName, r)
+ }
+
+ val suites = results.groupMap(_.suiteName)(identity).map { case (suiteName, tests) =>
+ val cases = tests.map { test =>
+ val failure =
+ (test.result.exceptionName, test.result.exceptionMsg, test.result.exceptionTrace) match {
+ case (Some(name), Some(msg), Some(trace)) =>
+ Some(
+
+ {
+ trace
+ .map(t =>
+ s"${t.getClassName}.${t.getMethodName}(${t.getFileName}:${t.getLineNumber})"
+ )
+ .mkString(s"${name}: ${msg}\n at ", "\n at ", "")
+ }
+
+ )
+ case _ => None
+ }
+
+ {failure.orNull}
+
+ }
+
+
+ {cases}
+
+ }
+
+ val xml =
+
+ {suites}
+
+ if (results.nonEmpty) scala.xml.XML.save(out.toString(), xml, xmlDecl = true)
+ }
+
+ private val RE_FQN = """^(([a-zA-Z_$][a-zA-Z\d_$]*\.)*[a-zA-Z_$][a-zA-Z\d_$]*)\.(.*)$""".r
+
+ private def splitFullyQualifiedName(fullyQualifiedName: String): (String, String) = {
+ RE_FQN.findFirstMatchIn(fullyQualifiedName) match {
+ case Some(m) => (m.group(1), m.group(3))
+ case None => ("", fullyQualifiedName)
+ }
+ }
}