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) + } + } }