diff --git a/main-actions/src/main/scala/sbt/Tests.scala b/main-actions/src/main/scala/sbt/Tests.scala index e4d227ee6..7b343989c 100644 --- a/main-actions/src/main/scala/sbt/Tests.scala +++ b/main-actions/src/main/scala/sbt/Tests.scala @@ -307,6 +307,7 @@ object Tests { in.filter(t => seen.add(f(t))) } + // Called by Defaults def apply( frameworks: Map[TestFramework, Framework], testLoader: ClassLoader, @@ -340,7 +341,7 @@ object Tests { apply(frameworks, testLoader, runners, o, config, log) } - def testTask( + private[sbt] def testTask( loader: ClassLoader, frameworks: Map[TestFramework, Framework], runners: Map[TestFramework, Runner], diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index ba01620d7..6191d690e 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1329,6 +1329,9 @@ object Defaults extends BuildCommon { .storeAs(definedTestNames) .triggeredBy(compile) .value, + definedTestDigests := IncrementalTest.definedTestDigestTask + .triggeredBy(compile) + .value, testQuick / testFilter := IncrementalTest.filterTask.value, executeTests := { import sbt.TupleSyntax.* @@ -1411,8 +1414,7 @@ object Defaults extends BuildCommon { ) +: TestStatusReporter( IncrementalTest.succeededFile((test / streams).value.cacheDirectory), - (Keys.test / fullClasspath).value, - fileConverter.value, + definedTestDigests.value, ) +: (TaskZero / testListeners).value }, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index e5c46c962..5e4d616e8 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -37,7 +37,7 @@ import sbt.librarymanagement._ import sbt.librarymanagement.ivy.{ Credentials, IvyConfiguration, IvyPaths, UpdateOptions } import sbt.nio.file.Glob import sbt.testing.Framework -import sbt.util.{ cacheLevel, ActionCacheStore, Level, Logger, LoggerContext } +import sbt.util.{ cacheLevel, ActionCacheStore, Digest, Level, Logger, LoggerContext } import xsbti.{ HashedVirtualFileRef, VirtualFile, VirtualFileRef } import xsbti.compile._ import xsbti.compile.analysis.ReadStamps @@ -349,8 +349,10 @@ object Keys { // Test Keys val testLoader = taskKey[ClassLoader]("Provides the class loader used for testing.").withRank(DTask) val loadedTestFrameworks = taskKey[Map[TestFramework, Framework]]("Loads Framework definitions from the test loader.").withRank(DTask) + @cacheLevel(include = Array.empty) val definedTests = taskKey[Seq[TestDefinition]]("Provides the list of defined tests.").withRank(BMinusTask) val definedTestNames = taskKey[Seq[String]]("Provides the set of defined test names.").withRank(BMinusTask) + val definedTestDigests = taskKey[Map[String, Digest]]("Provides a unique digest of defined tests.").withRank(DTask) val executeTests = taskKey[Tests.Output]("Executes all tests, producing a report.").withRank(CTask) 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) diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 2794a132c..21639e55a 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -11,7 +11,7 @@ package internal import java.io.File import java.util.concurrent.ConcurrentHashMap -import Keys.{ test, compileInputs, fileConverter, fullClasspath, streams } +import Keys.{ test, fileConverter, fullClasspath, streams } import sbt.Def.Initialize import sbt.internal.inc.Analysis import sbt.internal.util.Attributed @@ -32,17 +32,30 @@ object IncrementalTest: Def.task { val cp = (Keys.test / fullClasspath).value val s = (Keys.test / streams).value - val converter = fileConverter.value - val stamper = ClassStamper(cp, converter) + val digests = (Keys.definedTestDigests).value val succeeded = TestStatus.read(succeededFile(s.cacheDirectory)) def hasSucceeded(className: String): Boolean = succeeded.get(className) match case None => false - case Some(ts) => ts == stamper.transitiveStamp(className) + case Some(ts) => Some(ts) == digests.get(className) args => for filter <- selectedFilter(args) yield (test: String) => filter(test) && !hasSucceeded(test) } + // cache the test digests against the fullClasspath. + 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 converter = fileConverter.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) match + case Some(ts) => Seq(name -> ts) + case None => Nil + ): _*) + } + def succeededFile(dir: File): File = dir / "succeeded_tests.txt" def selectedFilter(args: Seq[String]): Seq[String => Boolean] = @@ -123,9 +136,10 @@ class ClassStamper( /** * Given a classpath and a class name, this tries to create a SHA-256 digest. */ - def transitiveStamp(className: String): Digest = + def transitiveStamp(className: String): Option[Digest] = val digests = SortedSet(analyses.flatMap(internalStamp(className, _, Set.empty)): _*) - Digest.sha256Hash(digests.toSeq: _*) + if digests.nonEmpty then Some(Digest.sha256Hash(digests.toSeq: _*)) + else None private def internalStamp( className: String, @@ -144,7 +158,7 @@ class ClassStamper( internalStamp(otherCN, analysis, alreadySeen + className) val internalJarDeps = relations .externalDeps(className) - .map: libClassName => + .flatMap: libClassName => transitiveStamp(libClassName) val externalDeps = relations .externalDeps(className) diff --git a/testing/src/main/scala/sbt/TestFramework.scala b/testing/src/main/scala/sbt/TestFramework.scala index e350d9bf1..4a5f47304 100644 --- a/testing/src/main/scala/sbt/TestFramework.scala +++ b/testing/src/main/scala/sbt/TestFramework.scala @@ -93,6 +93,7 @@ final class TestFramework(val implClassNames: String*) extends Serializable { def create(loader: ClassLoader, log: ManagedLogger): Option[Framework] = createFramework(loader, log, implClassNames.toList) } + final class TestDefinition( val name: String, val fingerprint: Fingerprint, @@ -108,7 +109,7 @@ final class TestDefinition( override def hashCode: Int = (name.hashCode, TestFramework.hashCode(fingerprint)).hashCode } -final class TestRunner( +private[sbt] final class TestRunner( delegate: Runner, listeners: Vector[TestReportListener], log: ManagedLogger @@ -214,7 +215,7 @@ object TestFramework { case _ => f.toString } - def testTasks( + private[sbt] def testTasks( frameworks: Map[TestFramework, Framework], runners: Map[TestFramework, Runner], testLoader: ClassLoader,