From 31cdedeae7764dfa5ad8672ff8dda3d133dff540 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Mon, 28 Sep 2026 02:03:22 -0400 Subject: [PATCH] [2.x] test: Compare sbt 1.x vs 2.x vs remote cache --- build.sbt | 2 +- .../communitybuild/ParsedCacheSummary.scala | 66 ++++++---- .../internal/communitybuild/projects.scala | 124 +++++++++++++++--- .../communitybuild/CommunityBuildTest.scala | 1 + .../ParsedCacheSummaryTest.scala | 62 +++++++-- 5 files changed, 195 insertions(+), 60 deletions(-) diff --git a/build.sbt b/build.sbt index 8975d602f..ded8ecfbc 100644 --- a/build.sbt +++ b/build.sbt @@ -1449,7 +1449,7 @@ lazy val `community-build` = (project in file("community-build")) scalaVersion := scala3, libraryDependencies ++= Seq(junit % Test, junitInterface % Test), Test / parallelExecution := false, - prepareCommunityBuild := { + prepareCommunityBuild := Def.uncached { val _ = (sbtRoot / publishLocalBinAll).value IO.write(baseDirectory.value / "target" / "sbt.version", version.value) }, diff --git a/community-build/src/main/scala/sbt/internal/communitybuild/ParsedCacheSummary.scala b/community-build/src/main/scala/sbt/internal/communitybuild/ParsedCacheSummary.scala index ffcc5b2f8..7fffd0370 100644 --- a/community-build/src/main/scala/sbt/internal/communitybuild/ParsedCacheSummary.scala +++ b/community-build/src/main/scala/sbt/internal/communitybuild/ParsedCacheSummary.scala @@ -7,7 +7,7 @@ import java.nio.file.{ Files, Paths, StandardOpenOption } final case class ParsedCacheSummary( success: Boolean, elapsedSeconds: Long, - hitRate: Double, + hitRate: Option[Double], remoteCacheHits: Int, diskCacheHits: Int, onsiteTasks: Int, @@ -44,25 +44,41 @@ object ParsedTestSummary: case _ => None end ParsedTestSummary -/** Collects cache summaries, attaching the preceding test summary to each. */ +/** + * Collects cache summaries, attaching the preceding test summary to each. + * Summaries without a cache hit rate (sbt 1.x) are used only when there are none with it, + * since sbt 2.x also prints a plain total after each cache summary. + */ final class CacheSummaryCollector: private var pendingTests: Option[ParsedTestSummary] = None - private val summaries = List.newBuilder[ParsedCacheSummary] + private val withCache = List.newBuilder[ParsedCacheSummary] + private val withoutCache = List.newBuilder[ParsedCacheSummary] def add(line: String): Unit = ParsedTestSummary.parse(line) match case Some(tests) => pendingTests = Some(tests) - case None => + case None => ParsedCacheSummary .parse(line) .foreach(summary => - summaries += summary.copy(tests = pendingTests) + val target = if summary.hitRate.isDefined then withCache else withoutCache + target += summary.copy(tests = pendingTests) pendingTests = None ) - def result(): List[ParsedCacheSummary] = summaries.result() + def result(): List[ParsedCacheSummary] = + val cached = withCache.result() + if cached.nonEmpty then cached else withoutCache.result() end CacheSummaryCollector +/** Describes one sbt invocation of a community project. */ +final case class RunInfo( + project: String, + run: String, + sbtVersion: String, + wallClockSeconds: Double, +) + object ParsedCacheSummary: private val Elapsed = """elapsed time: (\d+) s""".r.unanchored private val HitRate = """\bcache (\d+)%""".r.unanchored @@ -72,17 +88,16 @@ object ParsedCacheSummary: private val Errors = """(\d+) errors?\b""".r.unanchored def parse(line: String): Option[ParsedCacheSummary] = - for - elapsed <- first(Elapsed, line) - hitRate <- first(HitRate, line) - yield ParsedCacheSummary( - success = !line.contains("[error]"), - elapsedSeconds = elapsed.toLong, - hitRate = hitRate.toInt / 100.0, - remoteCacheHits = count(RemoteHits, line), - diskCacheHits = count(DiskHits, line), - onsiteTasks = count(Onsite, line), - errors = count(Errors, line), + first(Elapsed, line).map(elapsed => + ParsedCacheSummary( + success = !line.contains("[error]"), + elapsedSeconds = elapsed.toLong, + hitRate = first(HitRate, line).map(_.toInt / 100.0), + remoteCacheHits = count(RemoteHits, line), + diskCacheHits = count(DiskHits, line), + onsiteTasks = count(Onsite, line), + errors = count(Errors, line), + ) ) private def first(regex: scala.util.matching.Regex, line: String): Option[String] = @@ -92,27 +107,28 @@ object ParsedCacheSummary: first(regex, line).fold(0)(_.toInt) private[communitybuild] val tableHeader = - """|| Project | Status | Elapsed | Cache | Remote hits | Disk hits | Onsite tasks | Errors | Tests | Cached tests | - ||---|---|---|---|---|---|---|---|---|---| + """|| Project | Run | sbt | Status | Wall clock | Elapsed | Cache | Remote hits | Disk hits | Onsite tasks | Errors | Tests | Cached tests | + ||---|---|---|---|---|---|---|---|---|---|---|---|---| |""".stripMargin - def markdownRow(project: String, summary: ParsedCacheSummary): String = + def markdownRow(info: RunInfo, summary: ParsedCacheSummary): String = import summary.* val status = if success then "success" else "error" - val percent = f"${hitRate * 100}%.0f%%" + val wallClock = f"${info.wallClockSeconds}%.1fs" + val percent = hitRate.fold("")(rate => f"${rate * 100}%.0f%%") val testCount = tests.fold("")(_.total.toString) val cachedTests = tests.fold("")(_.cached.toString) - s"| `$project` | $status | ${elapsedSeconds}s | $percent | $remoteCacheHits | $diskCacheHits | $onsiteTasks | $errors | $testCount | $cachedTests |\n" + s"| `${info.project}` | ${info.run} | ${info.sbtVersion} | $status | $wallClock | ${elapsedSeconds}s | $percent | $remoteCacheHits | $diskCacheHits | $onsiteTasks | $errors | $testCount | $cachedTests |\n" - def report(project: String, summaries: Seq[ParsedCacheSummary]): Unit = - summaries.foreach(s => log(s"[$project] $s")) + def report(info: RunInfo, summaries: Seq[ParsedCacheSummary]): Unit = + summaries.foreach(s => log(s"[${info.project}] ${info.run}: $s")) sys.env .get("GITHUB_STEP_SUMMARY") .filter(_ => summaries.nonEmpty) .foreach(p => val path = Paths.get(p) val hasHeader = Files.exists(path) && Files.readString(path).contains(tableHeader) - val rows = summaries.map(markdownRow(project, _)).mkString + val rows = summaries.map(markdownRow(info, _)).mkString Files.writeString( path, (if hasHeader then "" else tableHeader) + rows, diff --git a/community-build/src/main/scala/sbt/internal/communitybuild/projects.scala b/community-build/src/main/scala/sbt/internal/communitybuild/projects.scala index 4983e2ab3..99dd3d11e 100644 --- a/community-build/src/main/scala/sbt/internal/communitybuild/projects.scala +++ b/community-build/src/main/scala/sbt/internal/communitybuild/projects.scala @@ -32,6 +32,30 @@ lazy val remoteCachePluginDir: Path = ) dir +lazy val sbt1PluginDir: Path = + val dir = communitybuildDir.resolve("target").resolve("sbt1-plugins") + Files.createDirectories(dir) + Files.writeString( + dir.resolve("plugins.sbt"), + """addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0") + |addSbtPlugin("com.github.sbt" % "sbt2-compat" % "0.2.0") + |""".stripMargin, + ) + Files.writeString( + dir.resolve("Sbt2Shims.scala"), + """import sbt._ + | + |object Sbt2Shims extends AutoPlugin { + | override def trigger = allRequirements + | object autoImport { + | val allowMismatchScala = settingKey[Boolean]("sbt 2.x shim") + | } + |} + |""".stripMargin, + ) + dir +end sbt1PluginDir + lazy val sbtPluginFilePath: String = // Workaround for https://github.com/sbt/sbt/issues/4395 new File(sys.props("user.home") + "/config/sbt/2/plugins").mkdirs() @@ -65,6 +89,7 @@ enum Scenario: case Test case Build case TestTest(minHitRate: Double, minTestHitRate: Double = 1.0) + case TestSbt1 sealed trait CommunityProject: def project: String @@ -74,6 +99,10 @@ sealed trait CommunityProject: def docCommand: String def binaryName: String def runCommandsArgs: List[String] = Nil + def sbt1CommandsArgs: List[String] = Nil + def sbt1TestCommand: String = testCommand + def warmupCommand: Option[String] = None + def sbt1WarmupCommand: Option[String] = None def environment: Map[String, String] = Map.empty def diskCacheDir: Option[File] = None def scenarioType: Scenario @@ -87,7 +116,7 @@ sealed trait CommunityProject: throw RuntimeException( s"Publish command is not specified for $project. Project details:\n$this" ) - val (exitCode, _) = execAndShutdown(runCommandsArgs :+ publishCommand) + val (exitCode, _) = execAndShutdown(runCommandsArgs :+ publishCommand, "publish") if exitCode != 0 then throw RuntimeException( s"Publish command exited with code $exitCode for project $project. Project details:\n$this" @@ -97,7 +126,7 @@ sealed trait CommunityProject: log(s"Documenting $project") if docCommand eq null then throw RuntimeException(s"Doc command is not specified for $project. Project details:\n$this") - val (exitCode, _) = execAndShutdown(runCommandsArgs :+ docCommand) + val (exitCode, _) = execAndShutdown(runCommandsArgs :+ docCommand, "doc") if exitCode != 0 then throw RuntimeException( s"Doc command exited with code $exitCode for project $project. Project details:\n$this" @@ -107,22 +136,33 @@ sealed trait CommunityProject: case Scenario.Test => test() case Scenario.Build => build() case Scenario.TestTest(minHitRate, minTestHitRate) => testTest(minHitRate, minTestHitRate) + case Scenario.TestSbt1 => testSbt1() - final def build(): Int = execAndShutdown(buildCommands)._1 + final def build(): Int = execAndShutdown(buildCommands, "build")._1 final def buildCommands = runCommandsArgs :+ testCompileCommand - final def test(): Int = execAndShutdown(runCommandsArgs :+ testCommand)._1 + final def test(): Int = execAndShutdown(runCommandsArgs :+ testCommand, "test")._1 + + /** Runs the tests twice using sbt 1.x. */ + final def testSbt1(): Int = + warmup(sbt1CommandsArgs, sbt1WarmupCommand) + val (firstExitCode, _) = + execAndShutdown(sbt1CommandsArgs :+ sbt1TestCommand, "test (1st)", sbt1CommandsArgs) + if firstExitCode != 0 then firstExitCode + else execAndShutdown(sbt1CommandsArgs :+ sbt1TestCommand, "test (2nd)", sbt1CommandsArgs)._1 /** Runs the tests twice, and asserts the second run is served from the cache. */ final def testTest(minHitRate: Double, minTestHitRate: Double): Int = - val (firstExitCode, _) = execAndShutdown(runCommandsArgs :+ testCommand) + warmup(runCommandsArgs, warmupCommand) + val (firstExitCode, _) = execAndShutdown(runCommandsArgs :+ testCommand, "test (1st)") if firstExitCode != 0 then firstExitCode else diskCacheDir.foreach(wipeDirectory) - val (exitCode, summaries) = execAndShutdown(runCommandsArgs :+ testCommand) + val (exitCode, summaries) = + execAndShutdown(runCommandsArgs :+ testCommand, "test (2nd, disk cache wiped)") assert(summaries.nonEmpty, s"no cache summary found in the second test run of $project") - val belowThreshold = summaries.filter(_.hitRate < minHitRate) + val belowThreshold = summaries.filter(_.hitRate.forall(_ < minHitRate)) assert( belowThreshold.isEmpty, s"cache hit rate of the second test run of $project is below $minHitRate: $belowThreshold" @@ -135,13 +175,22 @@ sealed trait CommunityProject: s"test hit rate of the second test run of $project is below $minTestHitRate: $testsBelowThreshold" ) exitCode + end testTest + + private def warmup(baseArgs: List[String], command: Option[String]): Unit = + command.foreach(cmd => execAndShutdown(baseArgs :+ cmd, "warm-up", baseArgs)) private def wipeDirectory(dir: File): Unit = log(s"Wiping disk cache $dir") IO.delete(IO.listFiles(dir)) - private def execAndShutdown(arguments: List[String]): (Int, List[ParsedCacheSummary]) = + private def execAndShutdown( + arguments: List[String], + run: String, + baseArgs: List[String] = runCommandsArgs, + ): (Int, List[ParsedCacheSummary]) = val summaries = CacheSummaryCollector() + val start = System.nanoTime() val exitCode = exec( projectDir, binaryName, @@ -151,20 +200,31 @@ sealed trait CommunityProject: println(line) summaries.add(line) ) - exec(projectDir, binaryName, runCommandsArgs :+ "shutdown", environment) + val commandEnd = System.nanoTime() + exec(projectDir, binaryName, baseArgs :+ "shutdown", environment) + val shutdownEnd = System.nanoTime() + val wallClockSeconds = (commandEnd - start) / 1e9 + log( + f"[$project] $run wall clock: $wallClockSeconds%.1f s command, ${(shutdownEnd - commandEnd) / 1e9}%.1f s shutdown" + ) + val sbtVersionUsed = baseArgs.collectFirst { case s"-Dsbt.version=$v" => v }.getOrElse("") val result = summaries.result() - ParsedCacheSummary.report(project, result) + ParsedCacheSummary.report(RunInfo(project, run, sbtVersionUsed, wallClockSeconds), result) (exitCode, result) + end execAndShutdown end CommunityProject -val sbt1Version = "1.12.1" +val sbt1Version = "1.13.0" val sbt2Version = "2.0.3" final case class SbtCommunityProject( project: String, testCmd: String = "test", testCompileCmd: String = "Test/compile", + sbt1TestCmd: Option[String] = None, + warmupCmd: Option[String] = None, + sbt1WarmupCmd: Option[String] = None, extraSbtArgs: List[String] = Nil, publishCmd: String = "publishLocal", docCmd: String = "doc", @@ -178,13 +238,19 @@ final case class SbtCommunityProject( scalacOptions.map("\"" + _ + "\"").mkString("List(", ",", ")") private val baseCommand = - "set Global/logLevel := Level.Error; " - ++ (if scalacOptions.isEmpty then "" - else s"""set Global/scalacOptions ++= $scalacOptionsString;""") + (if scalacOptions.isEmpty then "" + else s"""set Global/scalacOptions ++= $scalacOptionsString;""") - override val testCommand = - """set Global/testOptions += Tests.Argument(TestFramework("munit.Framework"), "+l"); """ - ++ s"$baseCommand$testCmd" + private def mkTestCommand(cmd: String): String = + s"$baseCommand$cmd" + + override val testCommand = mkTestCommand(testCmd) + + override def sbt1TestCommand: String = sbt1TestCmd.fold(testCommand)(mkTestCommand) + + override def warmupCommand: Option[String] = warmupCmd + + override def sbt1WarmupCommand: Option[String] = sbt1WarmupCmd override val testCompileCommand = s"$baseCommand$testCompileCmd" @@ -202,11 +268,21 @@ final case class SbtCommunityProject( override def diskCacheDir: Option[File] = Some(localCacheDir) + private val sbtProps: List[String] = Option(System.getProperty("sbt.ivy.home")) match + case Some(ivyHome) => List(s"-Dsbt.ivy.home=$ivyHome") + case _ => Nil + + override val sbt1CommandsArgs: List[String] = + extraSbtArgs ++ sbtProps ++ List( + s"-Dsbt.global.plugins=$sbt1PluginDir", + s"-Dsbt.version=$sbt1Version", + s"-Dsbt.boot=$bootDir", + "-Dsbt.supershell=false", + "--error", + ) + override val runCommandsArgs: List[String] = // Run the sbt command with the compiler version and sbt plugin set in the build - val sbtProps = Option(System.getProperty("sbt.ivy.home")) match - case Some(ivyHome) => List(s"-Dsbt.ivy.home=$ivyHome") - case _ => Nil val remoteCacheProps = SbtCommunityProject.remoteCache.toList.flatMap(uri => List( s"-Dsbt.global.plugins=$remoteCachePluginDir", @@ -218,6 +294,7 @@ final case class SbtCommunityProject( s"-Dsbt.boot=$bootDir", s"-Dsbt.global.localcache=$localCacheDir", "-Dsbt.supershell=false", + "--error", ) end SbtCommunityProject @@ -265,10 +342,17 @@ object projects: project = "chimney", testCmd = all(chimneyJvmProjects.map(p => s"$p/test")*), testCompileCmd = all(chimneyJvmProjects.map(p => s"$p/Test/compile")*), + warmupCmd = Some(all(chimneyJvmProjects.map(p => s"$p/update")*)), environment = Map("_JAVA_OPTIONS" -> "-Xmx2g"), scenarioType = Scenario.TestTest(minHitRate = 0.9), ) + lazy val `chimney-sbt1` = chimney.copy( + sbt1TestCmd = Some(all(chimneyJvmProjects.map(p => s"${p}3/test")*)), + sbt1WarmupCmd = Some(all(chimneyJvmProjects.map(p => s"${p}3/update")*)), + scenarioType = Scenario.TestSbt1, + ) + lazy val `sbt-compile-benchmark` = SbtCommunityProject( project = "sbt-compile-benchmark", scenarioType = Scenario.Build, diff --git a/community-build/src/test/scala/sbt/internal/communitybuild/CommunityBuildTest.scala b/community-build/src/test/scala/sbt/internal/communitybuild/CommunityBuildTest.scala index a308f9899..2f6ad665b 100644 --- a/community-build/src/test/scala/sbt/internal/communitybuild/CommunityBuildTest.scala +++ b/community-build/src/test/scala/sbt/internal/communitybuild/CommunityBuildTest.scala @@ -16,6 +16,7 @@ given testRunner: CommunityBuildRunner with @Category(Array(classOf[TestCategory])) class CommunityBuildTestA: + @Test def `chimney-sbt1` = projects.`chimney-sbt1`.run() @Test def chimney = projects.chimney.run() end CommunityBuildTestA diff --git a/community-build/src/test/scala/sbt/internal/communitybuild/ParsedCacheSummaryTest.scala b/community-build/src/test/scala/sbt/internal/communitybuild/ParsedCacheSummaryTest.scala index 7b0101a16..a136e02a9 100644 --- a/community-build/src/test/scala/sbt/internal/communitybuild/ParsedCacheSummaryTest.scala +++ b/community-build/src/test/scala/sbt/internal/communitybuild/ParsedCacheSummaryTest.scala @@ -11,27 +11,41 @@ class ParsedCacheSummaryTest: val summary = ParsedCacheSummary.parse( "[success] elapsed time: 4 s, cache 100%, 191 remote cache hits, 8 disk cache hits" ) - assert(summary == Some(ParsedCacheSummary(true, 4, 1.0, 191, 8, 0, 0))) + assert(summary == Some(ParsedCacheSummary(true, 4, Some(1.0), 191, 8, 0, 0))) @Test def parseSingularCountsAndDuration(): Unit = val summary = ParsedCacheSummary.parse( "[success] elapsed time: 74 s (0:01:14.0), cache 58%, 1 disk cache hit, 13 onsite tasks" ) - assert(summary == Some(ParsedCacheSummary(true, 74, 0.58, 0, 1, 13, 0))) + assert(summary == Some(ParsedCacheSummary(true, 74, Some(0.58), 0, 1, 13, 0))) @Test def parseErrors(): Unit = val summary = ParsedCacheSummary.parse( "[error] elapsed time: 9 s, cache 91%, 1624 disk cache hits, 157 onsite tasks, 35 errors" ) - assert(summary == Some(ParsedCacheSummary(false, 9, 0.91, 0, 1624, 157, 35))) + assert(summary == Some(ParsedCacheSummary(false, 9, Some(0.91), 0, 1624, 157, 35))) - @Test def ignoreLinesWithoutCache(): Unit = - assert(ParsedCacheSummary.parse("[success] elapsed time: 18 s").isEmpty) + @Test def parseElapsedWithoutCache(): Unit = + val summary = ParsedCacheSummary.parse("[success] elapsed time: 5 s") + assert(summary == Some(ParsedCacheSummary(true, 5, None, 0, 0, 0, 0))) + + @Test def ignoreLinesWithoutElapsed(): Unit = assert(ParsedCacheSummary.parse("[info] compiling 3 Scala sources").isEmpty) @Test def markdownRow(): Unit = - val row = ParsedCacheSummary.markdownRow("scalaz", ParsedCacheSummary(true, 4, 1.0, 191, 8, 0, 0)) - assert(row == "| `scalaz` | success | 4s | 100% | 191 | 8 | 0 | 0 | | |\n") + val info = RunInfo("scalaz", "build", "2.1.0-SNAPSHOT", 12.34) + val row = + ParsedCacheSummary.markdownRow(info, ParsedCacheSummary(true, 4, Some(1.0), 191, 8, 0, 0)) + assert( + row == "| `scalaz` | build | 2.1.0-SNAPSHOT | success | 12.3s | 4s | 100% | 191 | 8 | 0 | 0 | | |\n" + ) + + @Test def markdownRowWithoutCache(): Unit = + val info = RunInfo("chimney", "test (2nd)", "1.13.0", 9.5) + val row = ParsedCacheSummary.markdownRow(info, ParsedCacheSummary(true, 5, None, 0, 0, 0, 0)) + assert( + row == "| `chimney` | test (2nd) | 1.13.0 | success | 9.5s | 5s | | 0 | 0 | 0 | 0 | | |\n" + ) @Test def tableHeaderLines(): Unit = val lines = ParsedCacheSummary.tableHeader.linesIterator.toList @@ -39,11 +53,13 @@ class ParsedCacheSummaryTest: assert(lines.forall(line => line.startsWith("|") && line.endsWith("|"))) @Test def parseTestSummary(): Unit = - val tests = ParsedTestSummary.parse("[info] passed: total 94, failed 0, errors 0, passed 94, cached 94") + val tests = + ParsedTestSummary.parse("[info] passed: total 94, failed 0, errors 0, passed 94, cached 94") assert(tests == Some(ParsedTestSummary(94, 0, 0, 94, 94))) @Test def parseFailedTestSummary(): Unit = - val tests = ParsedTestSummary.parse("[error] failed: total 1, failed 1, errors 0, passed 0, cached 0") + val tests = + ParsedTestSummary.parse("[error] failed: total 1, failed 1, errors 0, passed 0, cached 0") assert(tests == Some(ParsedTestSummary(1, 1, 0, 0, 0))) @Test def collectorAttachesPrecedingTestSummary(): Unit = @@ -51,20 +67,38 @@ class ParsedCacheSummaryTest: List( "[info] passed: total 94, failed 0, errors 0, passed 94, cached 94", "[success] elapsed time: 5 s, cache 93%, 126 remote cache hits, 21 disk cache hits, 10 onsite tasks", + "[success] elapsed time: 5 s", "[success] elapsed time: 1 s, cache 100%, 3 disk cache hits", ).foreach(collector.add) val tests = Some(ParsedTestSummary(94, 0, 0, 94, 94)) assert( collector.result() == List( - ParsedCacheSummary(true, 5, 0.93, 126, 21, 10, 0, tests), - ParsedCacheSummary(true, 1, 1.0, 0, 3, 0, 0, None), + ParsedCacheSummary(true, 5, Some(0.93), 126, 21, 10, 0, tests), + ParsedCacheSummary(true, 1, Some(1.0), 0, 3, 0, 0, None), ) ) + @Test def collectorFallsBackToElapsedOnly(): Unit = + val collector = CacheSummaryCollector() + List("[info] compiling 3 Scala sources", "[success] elapsed time: 5 s").foreach(collector.add) + assert(collector.result() == List(ParsedCacheSummary(true, 5, None, 0, 0, 0, 0))) + @Test def markdownRowWithTests(): Unit = - val summary = ParsedCacheSummary(true, 5, 0.93, 126, 21, 10, 0, Some(ParsedTestSummary(94, 0, 0, 94, 94))) - val row = ParsedCacheSummary.markdownRow("chimney", summary) - assert(row == "| `chimney` | success | 5s | 93% | 126 | 21 | 10 | 0 | 94 | 94 |\n") + val info = RunInfo("chimney", "test (1st)", "2.1.0-SNAPSHOT", 9.7) + val summary = ParsedCacheSummary( + true, + 5, + Some(0.93), + 126, + 21, + 10, + 0, + Some(ParsedTestSummary(94, 0, 0, 94, 94)), + ) + val row = ParsedCacheSummary.markdownRow(info, summary) + assert( + row == "| `chimney` | test (1st) | 2.1.0-SNAPSHOT | success | 9.7s | 5s | 93% | 126 | 21 | 10 | 0 | 94 | 94 |\n" + ) @Test def testHitRate(): Unit = assert(ParsedTestSummary(94, 0, 0, 94, 94).testHitRate == 1.0)