[2.x] fix: Invalidate testQuick on test argument changes (#7680)

**Problem**
testQuick currently does not invalidate on argument changes.

**Solution**
This includes test argument digests.

---------

Co-authored-by: adpi2 <adrien.piquerez@gmail.com>
This commit is contained in:
eugene yokota 2024-09-17 17:48:53 -04:00 committed by GitHub
parent cb9a455915
commit 97823b18b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 75 additions and 25 deletions

View File

@ -32,8 +32,9 @@ import testing.{
import scala.annotation.tailrec import scala.annotation.tailrec
import scala.util.control.NonFatal import scala.util.control.NonFatal
import scala.quoted.*
import sbt.internal.util.ManagedLogger import sbt.internal.util.ManagedLogger
import sbt.util.Logger import sbt.util.{ Digest, Logger }
import sbt.protocol.testing.TestResult import sbt.protocol.testing.TestResult
import scala.runtime.AbstractFunction3 import scala.runtime.AbstractFunction3
@ -68,26 +69,58 @@ object Tests {
* The ClassLoader provided to `setup` is the loader containing the test classes that will be run. * The ClassLoader provided to `setup` is the loader containing the test classes that will be run.
* Setup is not currently performed for forked tests. * Setup is not currently performed for forked tests.
*/ */
final case class Setup(setup: ClassLoader => Unit) extends TestOption final case class Setup(setup: ClassLoader => Unit, codeDigest: Digest) extends TestOption
/** /**
* Defines a TestOption that will evaluate `setup` before any tests execute. * Defines a TestOption that will evaluate `setup` before any tests execute.
* Setup is not currently performed for forked tests. * Setup is not currently performed for forked tests.
*/ */
def Setup(setup: () => Unit) = new Setup(_ => setup()) inline def Setup(inline setup: () => Unit): Setup = ${ unitSetupMacro('setup) }
private def unitSetupMacro(fn: Expr[() => Unit])(using Quotes): Expr[Setup] =
val codeDigest = Digest.sha256Hash(fn.show.getBytes("UTF-8"))
val codeDigestStr = Expr(codeDigest.toString())
'{
new Setup(_ => $fn(), Digest($codeDigestStr))
}
private inline def Setup(inline setup: ClassLoader => Unit): Setup = ${ clSetupMacro('setup) }
def clSetupMacro(fn: Expr[ClassLoader => Unit])(using Quotes): Expr[Setup] =
val codeDigest = Digest.sha256Hash(fn.show.getBytes("UTF-8"))
val codeDigestStr = Expr(codeDigest.toString())
'{
new Setup($fn, Digest($codeDigestStr))
}
/** /**
* Defines a TestOption that will evaluate `cleanup` after all tests execute. * Defines a TestOption that will evaluate `cleanup` after all tests execute.
* The ClassLoader provided to `cleanup` is the loader containing the test classes that ran. * The ClassLoader provided to `cleanup` is the loader containing the test classes that ran.
* Cleanup is not currently performed for forked tests. * Cleanup is not currently performed for forked tests.
*/ */
final case class Cleanup(cleanup: ClassLoader => Unit) extends TestOption final case class Cleanup(cleanup: ClassLoader => Unit, codeDigest: Digest) extends TestOption
/** /**
* Defines a TestOption that will evaluate `cleanup` after all tests execute. * Defines a TestOption that will evaluate `cleanup` after all tests execute.
* Cleanup is not currently performed for forked tests. * Cleanup is not currently performed for forked tests.
*/ */
def Cleanup(cleanup: () => Unit) = new Cleanup(_ => cleanup()) inline def Cleanup(inline cleanup: () => Unit) = ${ unitCleanupMacro('cleanup) }
private def unitCleanupMacro(fn: Expr[() => Unit])(using Quotes): Expr[Cleanup] =
val codeDigest = Digest.sha256Hash(fn.show.getBytes("UTF-8"))
val codeDigestStr = Expr(codeDigest.toString())
'{
new Cleanup(_ => $fn(), Digest($codeDigestStr))
}
inline def Cleanup(inline cleanup: ClassLoader => Unit): Cleanup = ${ clCleanupMacro('cleanup) }
private def clCleanupMacro(fn: Expr[ClassLoader => Unit])(using Quotes): Expr[Cleanup] =
val codeDigest = Digest.sha256Hash(fn.show.getBytes("UTF-8"))
val codeDigestStr = Expr(codeDigest.toString())
'{
new Cleanup($fn, Digest($codeDigestStr))
}
/** The names of tests to explicitly exclude from execution. */ /** The names of tests to explicitly exclude from execution. */
final case class Exclude(tests: Iterable[String]) extends TestOption final case class Exclude(tests: Iterable[String]) extends TestOption
@ -271,11 +304,11 @@ object Tests {
if (orderedFilters.nonEmpty) sys.error("Cannot define multiple ordered test filters.") if (orderedFilters.nonEmpty) sys.error("Cannot define multiple ordered test filters.")
else orderedFilters = includes else orderedFilters = includes
() ()
case Exclude(exclude) => excludeTestsSet ++= exclude; () case Exclude(exclude) => excludeTestsSet ++= exclude; ()
case Listeners(listeners) => testListeners ++= listeners; () case Listeners(listeners) => testListeners ++= listeners; ()
case Setup(setupFunction) => setup += setupFunction; () case Setup(setupFunction, _) => setup += setupFunction; ()
case Cleanup(cleanupFunction) => cleanup += cleanupFunction; () case Cleanup(cleanupFunction, _) => cleanup += cleanupFunction; ()
case _: Argument => // now handled by whatever constructs `runners` case _: Argument => // now handled by whatever constructs `runners`
} }
} }

View File

@ -1317,6 +1317,7 @@ object Defaults extends BuildCommon {
testFrameworks :== sbt.TestFrameworks.All, testFrameworks :== sbt.TestFrameworks.All,
testListeners :== Nil, testListeners :== Nil,
testOptions :== Nil, testOptions :== Nil,
testOptionDigests := Nil,
testResultLogger :== TestResultLogger.Default, testResultLogger :== TestResultLogger.Default,
testOnly / testFilter :== (IncrementalTest.selectedFilter _), testOnly / testFilter :== (IncrementalTest.selectedFilter _),
extraTestDigests :== Nil, extraTestDigests :== Nil,
@ -1429,6 +1430,19 @@ object Defaults extends BuildCommon {
(TaskZero / testListeners).value (TaskZero / testListeners).value
}, },
testOptions := Tests.Listeners(testListeners.value) +: (TaskZero / testOptions).value, testOptions := Tests.Listeners(testListeners.value) +: (TaskZero / testOptions).value,
testOptionDigests := {
(TaskZero / testOptions).value.flatMap {
case Tests.Setup(_, digest) => Seq(digest)
case Tests.Cleanup(_, digest) => Seq(digest)
case Tests.Argument(fm, args) =>
Seq(
Digest.sha256Hash(
(fm.toSeq.map(_.toString) ++ args).mkString("\n").getBytes("UTF-8")
)
)
case _ => Nil
}
},
testExecution := testExecutionTask(key).value testExecution := testExecutionTask(key).value
) )
) ++ inScope(GlobalScope)( ) ++ inScope(GlobalScope)(

View File

@ -358,7 +358,9 @@ object Keys {
val test = taskKey[Unit]("Executes all tests.").withRank(APlusTask) val test = taskKey[Unit]("Executes all tests.").withRank(APlusTask)
val testOnly = inputKey[Unit]("Executes the tests provided as arguments or all tests if no arguments are provided.").withRank(ATask) val testOnly = inputKey[Unit]("Executes the tests provided as arguments or all tests if no arguments are provided.").withRank(ATask)
val testQuick = inputKey[Unit]("Executes the tests that either failed before, were not run or whose transitive dependencies changed, among those provided as arguments.").withRank(ATask) val testQuick = inputKey[Unit]("Executes the tests that either failed before, were not run or whose transitive dependencies changed, among those provided as arguments.").withRank(ATask)
@cacheLevel(include = Array.empty)
val testOptions = taskKey[Seq[TestOption]]("Options for running tests.").withRank(BPlusTask) val testOptions = taskKey[Seq[TestOption]]("Options for running tests.").withRank(BPlusTask)
private[sbt] val testOptionDigests = taskKey[Seq[Digest]]("Digest for testOptions").withRank(DTask)
val testFrameworks = settingKey[Seq[TestFramework]]("Registered, although not necessarily present, test frameworks.").withRank(CTask) val testFrameworks = settingKey[Seq[TestFramework]]("Registered, although not necessarily present, test frameworks.").withRank(CTask)
val testListeners = taskKey[Seq[TestReportListener]]("Defines test listeners.").withRank(DTask) val testListeners = taskKey[Seq[TestReportListener]]("Defines test listeners.").withRank(DTask)
val testForkedParallel = settingKey[Boolean]("Whether forked tests should be executed in parallel").withRank(CTask) val testForkedParallel = settingKey[Boolean]("Whether forked tests should be executed in parallel").withRank(CTask)

View File

@ -48,13 +48,14 @@ object IncrementalTest:
def definedTestDigestTask: Initialize[Task[Map[String, Digest]]] = Def.cachedTask { def definedTestDigestTask: Initialize[Task[Map[String, Digest]]] = Def.cachedTask {
val cp = (Keys.test / fullClasspath).value val cp = (Keys.test / fullClasspath).value
val testNames = Keys.definedTests.value.map(_.name).toVector.distinct val testNames = Keys.definedTests.value.map(_.name).toVector.distinct
val opts = (Keys.test / Keys.testOptionDigests).value
val converter = fileConverter.value val converter = fileConverter.value
val rds = Keys.resourceDigests.value val rds = Keys.resourceDigests.value
val extra = Keys.extraTestDigests.value val extra = Keys.extraTestDigests.value
val stamper = ClassStamper(cp, converter) val stamper = ClassStamper(cp, converter)
// TODO: Potentially do something about JUnit 5 and others which might not use class name // TODO: Potentially do something about JUnit 5 and others which might not use class name
Map((testNames.flatMap: name => Map((testNames.flatMap: name =>
stamper.transitiveStamp(name, extra ++ rds) match stamper.transitiveStamp(name, extra ++ rds ++ opts) match
case Some(ts) => Seq(name -> ts) case Some(ts) => Seq(name -> ts)
case None => Nil case None => Nil
): _*) ): _*)

View File

@ -1,6 +1,6 @@
val scalatest = "org.scalatest" %% "scalatest" % "3.0.5" val scalatest = "org.scalatest" %% "scalatest" % "3.0.5"
ThisBuild / scalaVersion := "2.12.19" scalaVersion := "2.12.19"
val foo = settingKey[Seq[String]]("foo") val foo = settingKey[Seq[String]]("foo")
val checkFoo = inputKey[Unit]("check contents of foo") val checkFoo = inputKey[Unit]("check contents of foo")

View File

@ -1,28 +1,28 @@
# should fail because it should run all test:tests, some of which are expected to fail (1 and 4) # should fail because it should run all test:tests, some of which are expected to fail (1 and 4)
-> test:test -> testQuick
$ touch success1 $ touch success1
> test:test > testQuick
$ delete success1 $ delete success1
$ touch failure1 $ touch failure1
-> test:test -> testQuick
$ delete failure1 $ delete failure1
$ touch success2 $ touch success2
> test:test > testQuick
$ delete success2 $ delete success2
$ touch failure2 $ touch failure2
-> test:test -> testQuick
$ delete failure2 $ delete failure2
$ touch success3 $ touch success3
> test:test > testQuick
$ delete success3 $ delete success3
$ touch failure3 $ touch failure3
-> test:test -> testQuick
$ delete failure3 $ delete failure3
> set Compile / scalacOptions += "-Xfatal-warnings" > set Compile / scalacOptions += "-Xfatal-warnings"

View File

@ -1,7 +1,7 @@
Test / testOptions += { Test / testOptions += {
val baseDir = baseDirectory.value val baseDir = baseDirectory.value
Tests.Setup { () => Tests.Setup { () =>
IO.touch(baseDir / "setup") IO.touch(baseDir / "setup")
} }
} }
@ -9,8 +9,8 @@ Test / testOptions += {
val t = baseDirectory.value / "tested" val t = baseDirectory.value / "tested"
val c = baseDirectory.value / "cleanup" val c = baseDirectory.value / "cleanup"
Tests.Cleanup { () => Tests.Cleanup { () =>
assert(t.exists, "Didn't exist: " + t.getAbsolutePath) // assert(t.exists, "Didn't exist: " + t.getAbsolutePath)
IO.delete(t) IO.delete(t)
IO.touch(c) IO.touch(c)
} }
} }

View File

@ -4,7 +4,7 @@ $ absent tested
$ absent cleanup $ absent cleanup
# without Setup configured, the setup file won't exist and the test will fail # without Setup configured, the setup file won't exist and the test will fail
-> test -> testQuick
# check that we are starting clean # check that we are starting clean
$ absent setup $ absent setup
@ -39,7 +39,7 @@ $ absent tested
$ absent cleanup $ absent cleanup
# without Setup configured, the setup file won't exist and the test will fail # without Setup configured, the setup file won't exist and the test will fail
-> test -> testQuick
# check that we are starting clean # check that we are starting clean
$ absent setup $ absent setup