diff --git a/main/actions/src/main/scala/sbt/TestResultLogger.scala b/main/actions/src/main/scala/sbt/TestResultLogger.scala new file mode 100644 index 000000000..c211035d2 --- /dev/null +++ b/main/actions/src/main/scala/sbt/TestResultLogger.scala @@ -0,0 +1,159 @@ +package sbt + +import sbt.Tests.{Output, Summary} + +/** + * Logs information about tests after they finish. + * + * Log output can be customised by providing a specialised instance of this + * trait via the `testTestResultLogger` setting. + * + * @since 0.13.5 + */ +trait TestResultLogger { + + /** + * Perform logging. + * + * @param log The target logger to write output to. + * @param results The test results about which to log. + * @param taskName The task about which we are logging. Eg. "my-module-b/test:test" + */ + def run(log: Logger, results: Output, taskName: String): Unit + + /** Only allow invocation if certain criteria is met, else use another `TestResultLogger` (defaulting to nothing) . */ + final def onlyIf(f: (Output, String) => Boolean, otherwise: TestResultLogger = TestResultLogger.Null) = + TestResultLogger.choose(f, this, otherwise) + + /** Allow invocation unless a certain predicate passes, in which case use another `TestResultLogger` (defaulting to nothing) . */ + final def unless(f: (Output, String) => Boolean, otherwise: TestResultLogger = TestResultLogger.Null) = + TestResultLogger.choose(f, otherwise, this) +} + +object TestResultLogger { + + /** A `TestResultLogger` that does nothing. */ + val Null = const(_ => ()) + + /** SBT's default `TestResultLogger`. Use `copy()` to change selective portions. */ + val Default = Defaults.Main() + + /** Twist on the default which is completely silent when the subject module doesn't contain any tests. */ + def SilentWhenNoTests = silenceWhenNoTests(Default) + + /** Creates a `TestResultLogger` using a given function. */ + def apply(f: (Logger, Output, String) => Unit): TestResultLogger = + new TestResultLogger { + override def run(log: Logger, results: Output, taskName: String) = + f(log, results, taskName) + } + + /** Creates a `TestResultLogger` that ignores its input and always performs the same logging. */ + def const(f: Logger => Unit) = apply((l,_,_) => f(l)) + + /** + * Selects a `TestResultLogger` based on a given predicate. + * + * @param t The `TestResultLogger` to choose if the predicate passes. + * @param f The `TestResultLogger` to choose if the predicate fails. + */ + def choose(cond: (Output, String) => Boolean, t: TestResultLogger, f: TestResultLogger) = + TestResultLogger((log, results, taskName) => + (if (cond(results, taskName)) t else f).run(log, results, taskName)) + + /** Transforms the input to be completely silent when the subject module doesn't contain any tests. */ + def silenceWhenNoTests(d: Defaults.Main) = + d.copy( + printStandard = d.printStandard.unless((results, _) => results.events.isEmpty), + printNoTests = Null + ) + + object Defaults { + + /** SBT's default `TestResultLogger`. Use `copy()` to change selective portions. */ + case class Main( + printStandard_? : Output => Boolean = Defaults.printStandard_?, + printSummary : TestResultLogger = Defaults.printSummary, + printStandard : TestResultLogger = Defaults.printStandard, + printFailures : TestResultLogger = Defaults.printFailures, + printNoTests : TestResultLogger = Defaults.printNoTests + ) extends TestResultLogger { + + override def run(log: Logger, results: Output, taskName: String): Unit = { + def run(r: TestResultLogger): Unit = r.run(log, results, taskName) + + run(printSummary) + + if (printStandard_?(results)) + run(printStandard) + + if (results.events.isEmpty) + run(printNoTests) + else + run(printFailures) + + results.overall match { + case TestResult.Error | TestResult.Failed => throw new TestsFailedException + case TestResult.Passed => + } + } + } + + val printSummary = TestResultLogger((log, results, _) => { + val multipleFrameworks = results.summaries.size > 1 + for (Summary(name, message) <- results.summaries) + if(message.isEmpty) + log.debug("Summary for " + name + " not available.") + else { + if(multipleFrameworks) log.info(name) + log.info(message) + } + }) + + val printStandard_? : Output => Boolean = + results => + // Print the standard one-liner statistic if no framework summary is defined, or when > 1 framework is in used. + results.summaries.size > 1 || results.summaries.headOption.forall(_.summaryText.size == 0) + + val printStandard = TestResultLogger((log, results, _) => { + val (skippedCount, errorsCount, passedCount, failuresCount, ignoredCount, canceledCount, pendingCount) = + results.events.foldLeft((0, 0, 0, 0, 0, 0, 0)) { case ((skippedAcc, errorAcc, passedAcc, failureAcc, ignoredAcc, canceledAcc, pendingAcc), (name, testEvent)) => + (skippedAcc + testEvent.skippedCount, errorAcc + testEvent.errorCount, passedAcc + testEvent.passedCount, failureAcc + testEvent.failureCount, + ignoredAcc + testEvent.ignoredCount, canceledAcc + testEvent.canceledCount, pendingAcc + testEvent.pendingCount) + } + val totalCount = failuresCount + errorsCount + skippedCount + passedCount + val base = s"Total $totalCount, Failed $failuresCount, Errors $errorsCount, Passed $passedCount" + + val otherCounts = Seq("Skipped" -> skippedCount, "Ignored" -> ignoredCount, "Canceled" -> canceledCount, "Pending" -> pendingCount) + val extra = otherCounts.filter(_._2 > 0).map{case(label,count) => s", $label $count" } + + val postfix = base + extra.mkString + results.overall match { + case TestResult.Error => log.error("Error: " + postfix) + case TestResult.Passed => log.info("Passed: " + postfix) + case TestResult.Failed => log.error("Failed: " + postfix) + } + }) + + val printFailures = TestResultLogger((log, results, _) => { + def select(resultTpe: TestResult.Value) = results.events collect { + case (name, tpe) if tpe.result == resultTpe => + scala.reflect.NameTransformer.decode(name) + } + + def show(label: String, level: Level.Value, tests: Iterable[String]): Unit = + if (!tests.isEmpty) { + log.log(level, label) + log.log(level, tests.mkString("\t", "\n\t", "")) + } + + show("Passed tests:", Level.Debug, select(TestResult.Passed)) + show("Failed tests:", Level.Error, select(TestResult.Failed)) + show("Error during tests:", Level.Error, select(TestResult.Error)) + }) + + val printNoTests = TestResultLogger((log, results, taskName) => + log.info("No tests to run for " + taskName) + ) + } +} diff --git a/main/actions/src/main/scala/sbt/Tests.scala b/main/actions/src/main/scala/sbt/Tests.scala index fb7f2cdcb..c75521ced 100644 --- a/main/actions/src/main/scala/sbt/Tests.scala +++ b/main/actions/src/main/scala/sbt/Tests.scala @@ -264,78 +264,10 @@ object Tests (tests, mains.toSet) } + @deprecated("Tests.showResults() has been superseded with TestResultLogger and setting 'testResultLogger'.", "0.13.5") def showResults(log: Logger, results: Output, noTestsMessage: =>String): Unit = - { - val multipleFrameworks = results.summaries.size > 1 - def printSummary(name: String, message: String) - { - if(message.isEmpty) - log.debug("Summary for " + name + " not available.") - else - { - if(multipleFrameworks) log.info(name) - log.info(message) - } - } - - for (Summary(name, messages) <- results.summaries) - printSummary(name, messages) - val noSummary = results.summaries.headOption.forall(_.summaryText.size == 0) - val printStandard = multipleFrameworks || noSummary - // Print the standard one-liner statistic if no framework summary is defined, or when > 1 framework is in used. - if (printStandard) - { - val (skippedCount, errorsCount, passedCount, failuresCount, ignoredCount, canceledCount, pendingCount) = - results.events.foldLeft((0, 0, 0, 0, 0, 0, 0)) { case ((skippedAcc, errorAcc, passedAcc, failureAcc, ignoredAcc, canceledAcc, pendingAcc), (name, testEvent)) => - (skippedAcc + testEvent.skippedCount, errorAcc + testEvent.errorCount, passedAcc + testEvent.passedCount, failureAcc + testEvent.failureCount, - ignoredAcc + testEvent.ignoredCount, canceledAcc + testEvent.canceledCount, pendingAcc + testEvent.pendingCount) - } - val totalCount = failuresCount + errorsCount + skippedCount + passedCount - val base = s"Total $totalCount, Failed $failuresCount, Errors $errorsCount, Passed $passedCount" - - val otherCounts = Seq("Skipped" -> skippedCount, "Ignored" -> ignoredCount, "Canceled" -> canceledCount, "Pending" -> pendingCount) - val extra = otherCounts.filter(_._2 > 0).map{case(label,count) => s", $label $count" } - - val postfix = base + extra.mkString - results.overall match { - case TestResult.Error => log.error("Error: " + postfix) - case TestResult.Passed => log.info("Passed: " + postfix) - case TestResult.Failed => log.error("Failed: " + postfix) - } - } - // Let's always print out Failed tests for now - if (results.events.isEmpty) - log.info(noTestsMessage) - else { - import TestResult.{Error, Failed, Passed} - import scala.reflect.NameTransformer.decode - - def select(resultTpe: TestResult.Value) = results.events collect { - case (name, tpe) if tpe.result == resultTpe => - decode(name) - } - - val failures = select(Failed) - val errors = select(Error) - val passed = select(Passed) - - def show(label: String, level: Level.Value, tests: Iterable[String]): Unit = - if(!tests.isEmpty) - { - log.log(level, label) - log.log(level, tests.mkString("\t", "\n\t", "")) - } - - show("Passed tests:", Level.Debug, passed ) - show("Failed tests:", Level.Error, failures) - show("Error during tests:", Level.Error, errors) - } - - results.overall match { - case TestResult.Error | TestResult.Failed => throw new TestsFailedException - case TestResult.Passed => - } - } + TestResultLogger.Default.copy(printNoTests = TestResultLogger.const(_ info noTestsMessage)) + .run(log, results, "") } final class TestsFailedException extends RuntimeException("Tests unsuccessful") with FeedbackProvidedException diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index d08c1e95b..96390d869 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -371,6 +371,7 @@ object Defaults extends BuildCommon }, testListeners :== Nil, testOptions :== Nil, + testResultLogger :== TestResultLogger.Default, testFilter in testOnly :== (selectedFilter _) )) lazy val testTasks: Seq[Setting[_]] = testTaskOptions(test) ++ testTaskOptions(testOnly) ++ testTaskOptions(testQuick) ++ testDefaults ++ Seq( @@ -380,16 +381,15 @@ object Defaults extends BuildCommon definedTestNames <<= definedTests map ( _.map(_.name).distinct) storeAs definedTestNames triggeredBy compile, testFilter in testQuick <<= testQuickFilter, executeTests <<= (streams in test, loadedTestFrameworks, testLoader, testGrouping in test, testExecution in test, fullClasspath in test, javaHome in test, testForkedParallel) flatMap allTestGroupsTask, + testResultLogger in (Test, test) :== TestResultLogger.SilentWhenNoTests, // https://github.com/sbt/sbt/issues/1185 test := { - implicit val display = Project.showContextKey(state.value) - Tests.showResults(streams.value.log, executeTests.value, noTestsMessage(resolvedScoped.value)) + val trl = (testResultLogger in (Test, test)).value + val taskName = Project.showContextKey(state.value)(resolvedScoped.value) + trl.run(streams.value.log, executeTests.value, taskName) }, testOnly <<= inputTests(testOnly), testQuick <<= inputTests(testQuick) ) - private[this] def noTestsMessage(scoped: ScopedKey[_])(implicit display: Show[ScopedKey[_]]): String = - "No tests to run for " + display(scoped) - lazy val TaskGlobal: Scope = ThisScope.copy(task = Global) lazy val ConfigGlobal: Scope = ThisScope.copy(config = Global) def testTaskOptions(key: Scoped): Seq[Setting[_]] = inTask(key)( Seq( @@ -488,9 +488,9 @@ object Defaults extends BuildCommon val modifiedOpts = Tests.Filters(filter(selected)) +: Tests.Argument(frameworkOptions : _*) +: config.options val newConfig = config.copy(options = modifiedOpts) val output = allTestGroupsTask(s, loadedTestFrameworks.value, testLoader.value, testGrouping.value, newConfig, fullClasspath.value, javaHome.value, testForkedParallel.value) - val processed = - for(out <- output) yield - Tests.showResults(s.log, out, noTestsMessage(resolvedScoped.value)) + val taskName = display(resolvedScoped.value) + val trl = testResultLogger.value + val processed = output.map(out => trl.run(s.log, out, taskName)) Def.value(processed) } } diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 16294ef80..3ea64983b 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -197,6 +197,7 @@ object Keys val testForkedParallel = SettingKey[Boolean]("test-forked-parallel", "Whether forked tests should be executed in parallel", CTask) val testExecution = TaskKey[Tests.Execution]("test-execution", "Settings controlling test execution", DTask) val testFilter = TaskKey[Seq[String] => Seq[String => Boolean]]("test-filter", "Filter controlling whether the test is executed", DTask) + val testResultLogger = SettingKey[TestResultLogger]("test-result-logger", "Logs results after a test task completes.", DTask) val testGrouping = TaskKey[Seq[Tests.Group]]("test-grouping", "Collects discovered tests into groups. Whether to fork and the options for forking are configurable on a per-group basis.", BMinusTask) val isModule = AttributeKey[Boolean]("is-module", "True if the target is a module.", DSetting) diff --git a/src/sphinx/Community/Changes.rst b/src/sphinx/Community/Changes.rst index 600a28f68..1c63180e5 100644 --- a/src/sphinx/Community/Changes.rst +++ b/src/sphinx/Community/Changes.rst @@ -5,6 +5,9 @@ Changes 0.13.2 to 0.13.5 ~~~~~~~~~~~~~~~~ - The Scala version for sbt and sbt plugins is now 2.10.4. This is a compatible version bump. +- Added a new setting ``testResultLogger`` to allow customisation of logging of test results. (gh-1225) +- When ``test`` is run and there are no tests available, omit logging output. + Especially useful for aggregate modules. ``test-only`` et al unaffected. (gh-1185) 0.13.1 to 0.13.2 ~~~~~~~~~~~~~~~~