[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.util.control.NonFatal
import scala.quoted.*
import sbt.internal.util.ManagedLogger
import sbt.util.Logger
import sbt.util.{ Digest, Logger }
import sbt.protocol.testing.TestResult
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.
* 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.
* 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.
* The ClassLoader provided to `cleanup` is the loader containing the test classes that ran.
* 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.
* 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. */
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.")
else orderedFilters = includes
()
case Exclude(exclude) => excludeTestsSet ++= exclude; ()
case Listeners(listeners) => testListeners ++= listeners; ()
case Setup(setupFunction) => setup += setupFunction; ()
case Cleanup(cleanupFunction) => cleanup += cleanupFunction; ()
case _: Argument => // now handled by whatever constructs `runners`
case Exclude(exclude) => excludeTestsSet ++= exclude; ()
case Listeners(listeners) => testListeners ++= listeners; ()
case Setup(setupFunction, _) => setup += setupFunction; ()
case Cleanup(cleanupFunction, _) => cleanup += cleanupFunction; ()
case _: Argument => // now handled by whatever constructs `runners`
}
}

View File

@ -1317,6 +1317,7 @@ object Defaults extends BuildCommon {
testFrameworks :== sbt.TestFrameworks.All,
testListeners :== Nil,
testOptions :== Nil,
testOptionDigests := Nil,
testResultLogger :== TestResultLogger.Default,
testOnly / testFilter :== (IncrementalTest.selectedFilter _),
extraTestDigests :== Nil,
@ -1429,6 +1430,19 @@ object Defaults extends BuildCommon {
(TaskZero / testListeners).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
)
) ++ inScope(GlobalScope)(

View File

@ -358,7 +358,9 @@ object Keys {
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 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)
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 testListeners = taskKey[Seq[TestReportListener]]("Defines test listeners.").withRank(DTask)
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 {
val cp = (Keys.test / fullClasspath).value
val testNames = Keys.definedTests.value.map(_.name).toVector.distinct
val opts = (Keys.test / Keys.testOptionDigests).value
val converter = fileConverter.value
val rds = Keys.resourceDigests.value
val extra = Keys.extraTestDigests.value
val stamper = ClassStamper(cp, converter)
// TODO: Potentially do something about JUnit 5 and others which might not use class 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 None => Nil
): _*)

View File

@ -1,6 +1,6 @@
val scalatest = "org.scalatest" %% "scalatest" % "3.0.5"
ThisBuild / scalaVersion := "2.12.19"
scalaVersion := "2.12.19"
val foo = settingKey[Seq[String]]("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)
-> test:test
-> testQuick
$ touch success1
> test:test
> testQuick
$ delete success1
$ touch failure1
-> test:test
-> testQuick
$ delete failure1
$ touch success2
> test:test
> testQuick
$ delete success2
$ touch failure2
-> test:test
-> testQuick
$ delete failure2
$ touch success3
> test:test
> testQuick
$ delete success3
$ touch failure3
-> test:test
-> testQuick
$ delete failure3
> set Compile / scalacOptions += "-Xfatal-warnings"

View File

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

View File

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