diff --git a/core/codesig/src/mill/codesig/CodeSig.scala b/core/codesig/src/mill/codesig/CodeSig.scala index 43db76fea32b..03079bfc8306 100644 --- a/core/codesig/src/mill/codesig/CodeSig.scala +++ b/core/codesig/src/mill/codesig/CodeSig.scala @@ -3,31 +3,63 @@ package mill.codesig import mill.codesig.JvmModel.* object CodeSig { - def compute( - classFiles: Seq[os.Path], - upstreamClasspath: Seq[os.Path], - ignoreCall: (Option[MethodDef], MethodSig) => Boolean, - logger: Logger, - prevTransitiveCallGraphHashesOpt: () => Option[Map[String, Int]] - ): CallGraphAnalysis = { - implicit val st: SymbolTable = new SymbolTable() + private def callGraphAnalysis( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path], + ignoreCall: (Option[MethodDef], MethodSig) => Boolean + )(implicit st: SymbolTable): CallGraphAnalysis = { val localSummary = LocalSummary.apply(classFiles.iterator.map(os.read.inputStream(_))) - logger.log(localSummary) val externalSummary = ExternalSummary.apply(localSummary, upstreamClasspath) - logger.log(externalSummary) val resolvedMethodCalls = ResolvedCalls.apply(localSummary, externalSummary) - logger.log(resolvedMethodCalls) new CallGraphAnalysis( localSummary, resolvedMethodCalls, externalSummary, - ignoreCall, - logger, - prevTransitiveCallGraphHashesOpt + ignoreCall ) } + + def getCallGraphAnalysis( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path], + ignoreCall: (Option[MethodDef], MethodSig) => Boolean + ): CallGraphAnalysis = { + implicit val st: SymbolTable = new SymbolTable() + + callGraphAnalysis(classFiles, upstreamClasspath, ignoreCall) + } + + def compute( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path], + ignoreCall: (Option[MethodDef], MethodSig) => Boolean, + logger: Logger, + prevTransitiveCallGraphHashesOpt: () => Option[Map[String, Int]] + ): CallGraphAnalysis = { + implicit val st: SymbolTable = new SymbolTable() + + val callAnalysis = callGraphAnalysis(classFiles, upstreamClasspath, ignoreCall) + + logger.log(callAnalysis.localSummary) + logger.log(callAnalysis.externalSummary) + logger.log(callAnalysis.resolved) + + logger.mandatoryLog(callAnalysis.methodCodeHashes) + logger.mandatoryLog(callAnalysis.prettyCallGraph) + logger.mandatoryLog(callAnalysis.transitiveCallGraphHashes0) + + logger.log(callAnalysis.transitiveCallGraphHashes) + + val spanningInvalidationTree = callAnalysis.calculateSpanningInvalidationTree { + prevTransitiveCallGraphHashesOpt() + } + + logger.mandatoryLog(spanningInvalidationTree) + + callAnalysis + } } diff --git a/core/codesig/src/mill/codesig/ExternalSummary.scala b/core/codesig/src/mill/codesig/ExternalSummary.scala index 7e8ac7de4d51..749bae2c051e 100644 --- a/core/codesig/src/mill/codesig/ExternalSummary.scala +++ b/core/codesig/src/mill/codesig/ExternalSummary.scala @@ -5,6 +5,7 @@ import mill.codesig.JvmModel.* import org.objectweb.asm.{ClassReader, ClassVisitor, MethodVisitor, Opcodes} import java.net.URLClassLoader +import scala.util.Try case class ExternalSummary( directMethods: Map[JCls, Map[MethodSig, Boolean]], @@ -47,7 +48,8 @@ object ExternalSummary { def load(cls: JCls): Unit = methodsPerCls.getOrElse(cls, load0(cls)) - def load0(cls: JCls): Unit = { + // Some macros implementations will fail the ClassReader, we can skip them + def load0(cls: JCls): Unit = Try { val visitor = new MyClassVisitor() val resourcePath = os.resource(upstreamClassloader) / os.SubPath(cls.name.replace('.', '/') + ".class") @@ -61,7 +63,7 @@ object ExternalSummary { methodsPerCls(cls) = visitor.methods ancestorsPerCls(cls) = visitor.ancestors ancestorsPerCls(cls).foreach(load) - } + }.getOrElse(()) (allDirectAncestors ++ allMethodCallParamClasses) .filter(!localSummary.contains(_)) diff --git a/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala b/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala index 9172ff416372..2909ddc1f452 100644 --- a/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala +++ b/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala @@ -2,18 +2,17 @@ package mill.codesig import mill.codesig.JvmModel.* import mill.internal.{SpanningForest, Tarjans} -import ujson.Obj +import ujson.{Obj, Arr} import upickle.default.{Writer, writer} import scala.collection.immutable.SortedMap +import scala.collection.mutable class CallGraphAnalysis( - localSummary: LocalSummary, - resolved: ResolvedCalls, - externalSummary: ExternalSummary, - ignoreCall: (Option[MethodDef], MethodSig) => Boolean, - logger: Logger, - prevTransitiveCallGraphHashesOpt: () => Option[Map[String, Int]] + val localSummary: LocalSummary, + val resolved: ResolvedCalls, + val externalSummary: ExternalSummary, + ignoreCall: (Option[MethodDef], MethodSig) => Boolean )(implicit st: SymbolTable) { val methods: Map[MethodDef, LocalSummary.MethodInfo] = for { @@ -40,8 +39,6 @@ class CallGraphAnalysis( lazy val methodCodeHashes: SortedMap[String, Int] = methods.map { case (k, vs) => (k.toString, vs.codeHash) }.to(SortedMap) - logger.mandatoryLog(methodCodeHashes) - lazy val prettyCallGraph: SortedMap[String, Array[CallGraphAnalysis.Node]] = { indexGraphEdges.zip(indexToNodes).map { case (vs, k) => (k.toString, vs.map(indexToNodes)) @@ -49,8 +46,6 @@ class CallGraphAnalysis( .to(SortedMap) } - logger.mandatoryLog(prettyCallGraph) - def transitiveCallGraphValues[V: scala.reflect.ClassTag]( nodeValues: Array[V], reduce: (V, V) => V, @@ -78,44 +73,45 @@ class CallGraphAnalysis( .collect { case (CallGraphAnalysis.LocalDef(d), v) => (d.toString, v) } .to(SortedMap) - logger.mandatoryLog(transitiveCallGraphHashes0) - logger.log(transitiveCallGraphHashes) - - lazy val spanningInvalidationTree: Obj = prevTransitiveCallGraphHashesOpt() match { - case Some(prevTransitiveCallGraphHashes) => - CallGraphAnalysis.spanningInvalidationTree( - prevTransitiveCallGraphHashes, - transitiveCallGraphHashes0, - indexToNodes, - indexGraphEdges - ) - case None => ujson.Obj() + def calculateSpanningInvalidationTree( + prevTransitiveCallGraphHashesOpt: => Option[Map[String, Int]] + ): Obj = { + prevTransitiveCallGraphHashesOpt match { + case Some(prevTransitiveCallGraphHashes) => + CallGraphAnalysis.spanningInvalidationTree( + prevTransitiveCallGraphHashes, + transitiveCallGraphHashes0, + indexToNodes, + indexGraphEdges + ) + case None => ujson.Obj() + } } - logger.mandatoryLog(spanningInvalidationTree) + def calculateInvalidatedClassNames( + prevTransitiveCallGraphHashesOpt: => Option[Map[String, Int]] + ): Set[String] = { + prevTransitiveCallGraphHashesOpt match { + case Some(prevTransitiveCallGraphHashes) => + CallGraphAnalysis.invalidatedClassNames( + prevTransitiveCallGraphHashes, + transitiveCallGraphHashes0, + indexToNodes, + indexGraphEdges + ) + case None => Set.empty + } + } } object CallGraphAnalysis { - /** - * Computes the minimal spanning forest of the that covers the nodes in the - * call graph whose transitive call graph hashes has changed since the last - * run, rendered as a JSON dictionary tree. This provides a great "debug - * view" that lets you easily Cmd-F to find a particular node and then trace - * it up the JSON hierarchy to figure out what upstream node was the root - * cause of the change in the callgraph. - * - * There are typically multiple possible spanning forests for a given graph; - * one is chosen arbitrarily. This is usually fine, since when debugging you - * typically are investigating why there's a path to a node at all where none - * should exist, rather than trying to fully analyse all possible paths - */ - def spanningInvalidationTree( + private def getSpanningForest( prevTransitiveCallGraphHashes: Map[String, Int], transitiveCallGraphHashes0: Array[(CallGraphAnalysis.Node, Int)], indexToNodes: Array[Node], indexGraphEdges: Array[Array[Int]] - ): ujson.Obj = { + ) = { val transitiveCallGraphHashes0Map = transitiveCallGraphHashes0.toMap val nodesWithChangedHashes = indexGraphEdges @@ -135,12 +131,64 @@ object CallGraphAnalysis { val reverseGraphEdges = indexGraphEdges.indices.map(reverseGraphMap.getOrElse(_, Array[Int]())).toArray + SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false) + } + + /** + * Computes the minimal spanning forest of the that covers the nodes in the + * call graph whose transitive call graph hashes has changed since the last + * run, rendered as a JSON dictionary tree. This provides a great "debug + * view" that lets you easily Cmd-F to find a particular node and then trace + * it up the JSON hierarchy to figure out what upstream node was the root + * cause of the change in the callgraph. + * + * There are typically multiple possible spanning forests for a given graph; + * one is chosen arbitrarily. This is usually fine, since when debugging you + * typically are investigating why there's a path to a node at all where none + * should exist, rather than trying to fully analyse all possible paths + */ + def spanningInvalidationTree( + prevTransitiveCallGraphHashes: Map[String, Int], + transitiveCallGraphHashes0: Array[(CallGraphAnalysis.Node, Int)], + indexToNodes: Array[Node], + indexGraphEdges: Array[Array[Int]] + ): ujson.Obj = { SpanningForest.spanningTreeToJsonTree( - SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false), + getSpanningForest(prevTransitiveCallGraphHashes, transitiveCallGraphHashes0, indexToNodes, indexGraphEdges), k => indexToNodes(k).toString ) } + /** + * Get all class names that have their hashcode changed compared to prevTransitiveCallGraphHashes + */ + def invalidatedClassNames( + prevTransitiveCallGraphHashes: Map[String, Int], + transitiveCallGraphHashes0: Array[(CallGraphAnalysis.Node, Int)], + indexToNodes: Array[Node], + indexGraphEdges: Array[Array[Int]] + ): Set[String] = { + val rootNode = getSpanningForest(prevTransitiveCallGraphHashes, transitiveCallGraphHashes0, indexToNodes, indexGraphEdges) + + val jsonValueQueue = mutable.ArrayDeque[(Int, SpanningForest.Node)]() + jsonValueQueue.appendAll(rootNode.values.toSeq) + val builder = Set.newBuilder[String] + + while (jsonValueQueue.nonEmpty) { + val (nodeIndex, node) = jsonValueQueue.removeHead() + node.values.foreach { case (childIndex, childNode) => + jsonValueQueue.append((childIndex, childNode)) + } + indexToNodes(nodeIndex) match { + case CallGraphAnalysis.LocalDef(methodDef) => builder.addOne(methodDef.cls.name) + case CallGraphAnalysis.Call(methodCall) => builder.addOne(methodCall.cls.name) + case CallGraphAnalysis.ExternalClsCall(externalCls) => builder.addOne(externalCls.name) + } + } + + builder.result() + } + def indexGraphEdges( indexToNodes: Array[Node], methods: Map[MethodDef, LocalSummary.MethodInfo], diff --git a/core/define/src/mill/define/Task.scala b/core/define/src/mill/define/Task.scala index 2fa3d07211f8..abeb394263c4 100644 --- a/core/define/src/mill/define/Task.scala +++ b/core/define/src/mill/define/Task.scala @@ -124,7 +124,16 @@ object Task extends TaskBase { inline def Command[T](inline t: Result[T])(implicit inline w: W[T], inline ctx: mill.define.ModuleCtx - ): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }) } + ): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }, persistent = '{ false }) } + + + /** + * This version allow [[Command]] to be persistent + */ + inline def Command[T](inline persistent: Boolean)(inline t: Result[T])(implicit + inline w: W[T], + inline ctx: mill.define.ModuleCtx + ): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }, persistent = '{ persistent }) } /** * @param exclusive Exclusive commands run serially at the end of an evaluation, @@ -142,7 +151,7 @@ object Task extends TaskBase { inline def apply[T](inline t: Result[T])(implicit inline w: W[T], inline ctx: mill.define.ModuleCtx - ): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, '{ this.exclusive }) } + ): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, '{ this.exclusive }, '{ false }) } } /** @@ -396,7 +405,8 @@ class Command[+T]( val ctx0: mill.define.ModuleCtx, val writer: W[?], val isPrivate: Option[Boolean], - val exclusive: Boolean + val exclusive: Boolean, + override val persistent: Boolean ) extends NamedTask[T] { override def asCommand: Some[Command[T]] = Some(this) @@ -543,12 +553,13 @@ private object TaskMacros { )(t: Expr[Result[T]])( w: Expr[W[T]], ctx: Expr[mill.define.ModuleCtx], - exclusive: Expr[Boolean] + exclusive: Expr[Boolean], + persistent: Expr[Boolean] ): Expr[Command[T]] = { appImpl[Command, T]( (in, ev) => '{ - new Command[T]($in, $ev, $ctx, $w, ${ taskIsPrivate() }, exclusive = $exclusive) + new Command[T]($in, $ev, $ctx, $w, ${ taskIsPrivate() }, exclusive = $exclusive, persistent = $persistent) }, t ) diff --git a/example/javalib/testing/7-test-quick/build.mill b/example/javalib/testing/7-test-quick/build.mill new file mode 100644 index 000000000000..77b86efac2e5 --- /dev/null +++ b/example/javalib/testing/7-test-quick/build.mill @@ -0,0 +1,33 @@ +//// SNIPPET:BUILD1 +package build +import mill._, javalib._ +import os._ + +object foo extends JavaModule { + object test extends JavaTests { + def testFramework = "com.novocode.junit.JUnitFramework" // Use JUnit 4 framework interface + def mvnDeps = Seq( + mvn"junit:junit:4.13.2", // JUnit 4 itself + mvn"com.novocode:junit-interface:0.11" // sbt-compatible JUnit interface + ) + } + // Ultilities for replacing text in files + def replaceBar(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/src/Bar.java") + val filePath = Task.dest() / relativePath + os.write.over(filePath, os.read(filePath).replace( + """return String.format("Hi, %s!", name);""", + """return String.format("Ciao, %s!", name);""" + )) + } + + def replaceFooTest2(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/test/src/FooTest2.java") + val filePath = Task.dest() / relativePath + os.write.over(filePath, os.read(filePath).replace( + """assertEquals("Hi, " + name + "!", greeted);""", + """assertEquals("Ciao, " + name + "!", greeted);""", + )) + } +} +//// SNIPPET:END diff --git a/example/javalib/testing/7-test-quick/foo/src/Bar.java b/example/javalib/testing/7-test-quick/foo/src/Bar.java new file mode 100644 index 000000000000..75c01ba00175 --- /dev/null +++ b/example/javalib/testing/7-test-quick/foo/src/Bar.java @@ -0,0 +1,11 @@ +package foo; + +public class Bar { + public static String greet(String name) { + return String.format("Hello, %s!", name); + } + + public static String greet2(String name) { + return String.format("Hi, %s!", name); + } +} \ No newline at end of file diff --git a/example/javalib/testing/7-test-quick/foo/src/Foo.java b/example/javalib/testing/7-test-quick/foo/src/Foo.java new file mode 100644 index 000000000000..c423f3df40c8 --- /dev/null +++ b/example/javalib/testing/7-test-quick/foo/src/Foo.java @@ -0,0 +1,15 @@ +package foo; + +public class Foo { + public static void main(String[] args) { + System.out.println(Bar.greet("World")); + } + + public static String greet(String name) { + return Bar.greet(name); + } + + public static String greet2(String name) { + return Bar.greet2(name); + } +} \ No newline at end of file diff --git a/example/javalib/testing/7-test-quick/foo/test/src/FooTest1.java b/example/javalib/testing/7-test-quick/foo/test/src/FooTest1.java new file mode 100644 index 000000000000..68aad611782c --- /dev/null +++ b/example/javalib/testing/7-test-quick/foo/test/src/FooTest1.java @@ -0,0 +1,13 @@ +package foo; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class FooTest1 { + @Test + public void test1() { + String name = "Aether"; + String greeted = Foo.greet(name); + assertEquals("Hello, " + name + "!", greeted); + } +} \ No newline at end of file diff --git a/example/javalib/testing/7-test-quick/foo/test/src/FooTest2.java b/example/javalib/testing/7-test-quick/foo/test/src/FooTest2.java new file mode 100644 index 000000000000..a5dca092b4d9 --- /dev/null +++ b/example/javalib/testing/7-test-quick/foo/test/src/FooTest2.java @@ -0,0 +1,13 @@ +package foo; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class FooTest2 { + @Test + public void test2() { + String name = "Bob"; + String greeted = Foo.greet2(name); + assertEquals("Hi, " + name + "!", greeted); + } +} \ No newline at end of file diff --git a/example/kotlinlib/testing/7-test-quick/build.mill b/example/kotlinlib/testing/7-test-quick/build.mill new file mode 100644 index 000000000000..8cd98c77a525 --- /dev/null +++ b/example/kotlinlib/testing/7-test-quick/build.mill @@ -0,0 +1,38 @@ +//// SNIPPET:BUILD1 +package build +import mill._, kotlinlib._ +import os._ + +object foo extends KotlinModule { + def kotlinVersion = "1.9.24" // Specify your Kotlin version + + object test extends KotlinTests { + def testFramework = "com.github.sbt.junit.jupiter.api.JupiterFramework" // Use JUnit 5 framework + def mvnDeps = Seq( + // JUnit 5 dependencies + mvn"org.junit.jupiter:junit-jupiter-api:5.10.0", + mvn"org.junit.jupiter:junit-jupiter-engine:5.10.0", + // Test runner interface for Mill/sbt + mvn"com.github.sbt.junit:jupiter-interface:0.13.3" + ) + } + + def replaceBar(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/src/Bar.kt") + val filePath = T.dest / relativePath + os.write.over(filePath, os.read(filePath).replace( + """return "Hi, $name!"""", + """return "Ciao, $name!"""" + )) + } + + def replaceFooTest2(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/test/src/FooTest2.kt") + val filePath = T.dest / relativePath + os.write.over(filePath, os.read(filePath).replace( + """assertEquals("Hi, $name!", greeted)""", + """assertEquals("Ciao, $name!", greeted)""" + )) + } +} +//// SNIPPET:END diff --git a/example/kotlinlib/testing/7-test-quick/foo/src/Bar.kt b/example/kotlinlib/testing/7-test-quick/foo/src/Bar.kt new file mode 100644 index 000000000000..aa283763431e --- /dev/null +++ b/example/kotlinlib/testing/7-test-quick/foo/src/Bar.kt @@ -0,0 +1,11 @@ +package foo + +object Bar { + fun greet(name: String): String { + return "Hello, $name!" + } + + fun greet2(name: String): String { + return "Hi, $name!" + } +} \ No newline at end of file diff --git a/example/kotlinlib/testing/7-test-quick/foo/src/Foo.kt b/example/kotlinlib/testing/7-test-quick/foo/src/Foo.kt new file mode 100644 index 000000000000..2340c91b4aba --- /dev/null +++ b/example/kotlinlib/testing/7-test-quick/foo/src/Foo.kt @@ -0,0 +1,16 @@ +package foo + +object Foo { + @JvmStatic + fun main(args: Array) { + println(Bar.greet("World")) + } + + fun greet(name: String): String { + return Bar.greet(name) + } + + fun greet2(name: String): String { + return Bar.greet2(name) + } +} \ No newline at end of file diff --git a/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest1.kt b/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest1.kt new file mode 100644 index 000000000000..06bad4e4e476 --- /dev/null +++ b/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest1.kt @@ -0,0 +1,13 @@ +package foo + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* + +class FooTest1 { + @Test + fun test1() { + val name = "Aether" + val greeted = Foo.greet(name) + assertEquals("Hello, $name!", greeted) + } +} \ No newline at end of file diff --git a/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest2.kt b/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest2.kt new file mode 100644 index 000000000000..38508ba55e00 --- /dev/null +++ b/example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest2.kt @@ -0,0 +1,13 @@ +package foo + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* + +class FooTest2 { + @Test + fun test2() { + val name = "Bob" + val greeted = Foo.greet2(name) + assertEquals("Hi, $name!", greeted) + } +} \ No newline at end of file diff --git a/example/scalalib/testing/7-test-quick/build.mill b/example/scalalib/testing/7-test-quick/build.mill new file mode 100644 index 000000000000..b53e7b88b07e --- /dev/null +++ b/example/scalalib/testing/7-test-quick/build.mill @@ -0,0 +1,84 @@ +// Mill’s `testQuick` command enables efficient, fine-grained selective test execution for JVM projects. +// Between run, Mill leverages it's bytecode analysis to determine which test classes are potentially impacted by recent code changes, +// and executes only those tests. If a test fails, it will continue to be executed on future runs until it passes, regardless of further code changes. + +//// SNIPPET:BUILD1 +package build +import mill._, scalalib._ +import os._ + +object foo extends ScalaModule { + def scalaVersion = "2.13.8" + object test extends ScalaTests { + def mvnDeps = Seq(mvn"com.lihaoyi::utest:0.8.5") + def testFramework = "utest.runner.Framework" + } + + // Utilities for replacing text in files + def replaceBar(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/src/Bar.scala") + val filePath = Task.dest() / relativePath + os.write.over(filePath, os.read(filePath).replace( + """s"Hi, $name!"""", + """s"Ciao, $name!"""" + )) + } + + def replaceFooTest2(args: String*) = Task.Command { + val relativePath = os.RelPath("../../../foo/test/src/FooTest2.scala") + val filePath = Task.dest() / relativePath + os.write.over(filePath, os.read(filePath).replace( + """assert(greeted == s"Hi, $name!")""", + """assert(greeted == s"Ciao, $name!")""" + )) + } +} + +//// SNIPPET:END + +/** Usage +> mill -j 1 foo.test.testQuick # First run executes all tests, resulting in 2 passed tests + +> cat out/foo/test/testQuick.dest/worker-0/result.log # Result log shows [2,0] indicating 2 passed tests, 0 failed +[2,0] + +> mill -j 1 foo.test.testQuick # Second run skips all tests since no code changes were made + +> cat out/foo/test/testQuick.dest/worker-0/result.log # Result log shows [0,0] indicating no tests were executed +[0,0] + +> mill -j 1 foo.replaceBar # Modify Bar.scala to change greeting from "Hi" to "Ciao" + +> mill -j 1 foo.test.testQuick # Third run executes only FooTest2 which fails due to greeting mismatch +error: ... + +> cat out/foo/test/testQuick.dest/worker-0/result.log # Result log shows [0,1] indicating 0 passed tests, 1 failed +[0,1] + +> mill -j 1 foo.test.testQuick # Fourth run re-runs failing FooTest2 even without code changes +error: ... + +> cat out/foo/test/testQuick.dest/worker-0/result.log # Result log shows [0,1] as test continues to fail +[0,1] + +> mill -j 1 foo.replaceFooTest2 # Update test assertion to expect "Ciao" instead of "Hi" + +> mill -j 1 foo.test.testQuick # Fifth run executes FooTest2 which now passes + +> cat out/foo/test/testQuick.dest/worker-0/result.log # Result log shows [1,0] indicating the previously failing test now passes +[1,0] +*/ + +// In this example, `testQuick` is used to demonstrate fine-grained, change-driven test execution within a module: +// +// - On the first invocation, `mill foo.test.testQuick` executes all test classes in the module and reports the results. +// - If you invoke `testQuick` again without modifying any source files, Mill detects that there are no relevant changes +// and skips test execution entirely. +// - When you update a source file, Mill analyzes the bytecode and determines which test classes are potentially impacted by the change. +// Only those affected tests are executed on the next run. +// - If a test fails, it will continue to be executed on subsequent runs until it passes, regardless of whether further code changes are made. +// - Once the failing test is fixed, and the test passes, +// `testQuick` will again skip running tests unless new changes are detected. +// +// This approach allows you to focus on the tests that matter after each change, minimizing unnecessary test execution and reducing feedback time, +// especially in large or monolithic projects. diff --git a/example/scalalib/testing/7-test-quick/foo/src/Bar.scala b/example/scalalib/testing/7-test-quick/foo/src/Bar.scala new file mode 100644 index 000000000000..990ce4074b79 --- /dev/null +++ b/example/scalalib/testing/7-test-quick/foo/src/Bar.scala @@ -0,0 +1,11 @@ +package foo + +object Bar { + def greet(name: String): String = { + s"Hello, $name!" + } + + def greet2(name: String): String = { + s"Hi, $name!" + } +} diff --git a/example/scalalib/testing/7-test-quick/foo/src/Foo.scala b/example/scalalib/testing/7-test-quick/foo/src/Foo.scala new file mode 100644 index 000000000000..9a18834ae67b --- /dev/null +++ b/example/scalalib/testing/7-test-quick/foo/src/Foo.scala @@ -0,0 +1,13 @@ +package foo + +import foo.Bar + +object Foo { + def main(args: Array[String]): Unit = { + println(Bar.greet("World")) + } + + def greet(name: String): String = Bar.greet(name) + + def greet2(name: String): String = Bar.greet2(name) +} diff --git a/example/scalalib/testing/7-test-quick/foo/test/src/FooTest1.scala b/example/scalalib/testing/7-test-quick/foo/test/src/FooTest1.scala new file mode 100644 index 000000000000..0d0fb12ac6ef --- /dev/null +++ b/example/scalalib/testing/7-test-quick/foo/test/src/FooTest1.scala @@ -0,0 +1,11 @@ +package foo +import utest._ +object FooTest1 extends TestSuite { + def tests = Tests { + test("test1") { + val name = "Aether" + val greeted = Foo.greet(name) + assert(greeted == s"Hello, $name!") + } + } +} diff --git a/example/scalalib/testing/7-test-quick/foo/test/src/FooTest2.scala b/example/scalalib/testing/7-test-quick/foo/test/src/FooTest2.scala new file mode 100644 index 000000000000..3a82ce8ecf65 --- /dev/null +++ b/example/scalalib/testing/7-test-quick/foo/test/src/FooTest2.scala @@ -0,0 +1,11 @@ +package foo +import utest._ +object FooTest2 extends TestSuite { + def tests = Tests { + test("test2") { + val name = "Bob" + val greeted = Foo.greet2(name) + assert(greeted == s"Hi, $name!") + } + } +} diff --git a/integration/feature/test-quick/resources/app/src/MyNumber.scala b/integration/feature/test-quick/resources/app/src/MyNumber.scala new file mode 100644 index 000000000000..e9e70b5f6426 --- /dev/null +++ b/integration/feature/test-quick/resources/app/src/MyNumber.scala @@ -0,0 +1,21 @@ +package app + +import lib.* + +final case class MyNumber(val value: Int) + +object MyNumber { + + given gCombinator: Combinator[MyNumber] = new Combinator[MyNumber] { + def combine(a: MyNumber, b: MyNumber): MyNumber = MyNumber(a.value + b.value) + } + + given gDefaultValue: DefaultValue[MyNumber] = new DefaultValue[MyNumber] { + def defaultValue: MyNumber = MyNumber(0) + } + + def combine(a: MyNumber, b: MyNumber, c: MyNumber): MyNumber = gCombinator.combine2(a, b, c) + + def defaultValue: MyNumber = gDefaultValue.defaultValue + +} diff --git a/integration/feature/test-quick/resources/app/src/MyString.scala b/integration/feature/test-quick/resources/app/src/MyString.scala new file mode 100644 index 000000000000..af7a0945545f --- /dev/null +++ b/integration/feature/test-quick/resources/app/src/MyString.scala @@ -0,0 +1,21 @@ +package app + +import lib.* + +final case class MyString(val value: String) + +object MyString { + + given gCombinator: Combinator[MyString] = new Combinator[MyString] { + def combine(a: MyString, b: MyString): MyString = MyString(a.value + b.value) + } + + given gDefaultValue: DefaultValue[MyString] = new DefaultValue[MyString] { + def defaultValue: MyString = MyString("") + } + + def combine(a: MyString, b: MyString, c: MyString): MyString = gCombinator.combine2(a, b, c) + + def defaultValue: MyString = gDefaultValue.defaultValue + +} diff --git a/integration/feature/test-quick/resources/app/test/src/MyNumberCombinatorTests.scala b/integration/feature/test-quick/resources/app/test/src/MyNumberCombinatorTests.scala new file mode 100644 index 000000000000..6d075b86fa0a --- /dev/null +++ b/integration/feature/test-quick/resources/app/test/src/MyNumberCombinatorTests.scala @@ -0,0 +1,16 @@ +package app + +import utest.* +import app.MyNumber + +object MyNumberCombinatorTests extends TestSuite { + def tests = Tests { + test("simple") { + val a = MyNumber(1) + val b = MyNumber(2) + val c = MyNumber(3) + val result = MyNumber.combine(a, b, c) + assert(result == MyNumber(6)) + } + } +} diff --git a/integration/feature/test-quick/resources/app/test/src/MyNumberDefaultValueTests.scala b/integration/feature/test-quick/resources/app/test/src/MyNumberDefaultValueTests.scala new file mode 100644 index 000000000000..b23ce29015e8 --- /dev/null +++ b/integration/feature/test-quick/resources/app/test/src/MyNumberDefaultValueTests.scala @@ -0,0 +1,13 @@ +package app + +import utest.* +import app.MyNumber + +object MyNumberDefaultValueTests extends TestSuite { + def tests = Tests { + test("simple") { + val result = MyNumber.defaultValue + assert(result == MyNumber(0)) + } + } +} diff --git a/integration/feature/test-quick/resources/app/test/src/MyStringCombinatorTests.scala b/integration/feature/test-quick/resources/app/test/src/MyStringCombinatorTests.scala new file mode 100644 index 000000000000..e463b80044f1 --- /dev/null +++ b/integration/feature/test-quick/resources/app/test/src/MyStringCombinatorTests.scala @@ -0,0 +1,16 @@ +package app + +import utest.* +import app.MyString + +object MyStringCombinatorTests extends TestSuite { + def tests = Tests { + test("simple") { + val a = MyString("a") + val b = MyString("b") + val c = MyString("c") + val result = MyString.combine(a, b, c) + assert(result == MyString("abc")) + } + } +} diff --git a/integration/feature/test-quick/resources/app/test/src/MyStringDefaultValueTests.scala b/integration/feature/test-quick/resources/app/test/src/MyStringDefaultValueTests.scala new file mode 100644 index 000000000000..aee8a248502b --- /dev/null +++ b/integration/feature/test-quick/resources/app/test/src/MyStringDefaultValueTests.scala @@ -0,0 +1,13 @@ +package app + +import utest.* +import app.MyString + +object MyStringDefaultValueTests extends TestSuite { + def tests = Tests { + test("simple") { + val result = MyString.defaultValue + assert(result == MyString("")) + } + } +} diff --git a/integration/feature/test-quick/resources/build.mill b/integration/feature/test-quick/resources/build.mill new file mode 100644 index 000000000000..1777a7437e5a --- /dev/null +++ b/integration/feature/test-quick/resources/build.mill @@ -0,0 +1,18 @@ +package build + +import mill._, scalalib._ + +object lib extends ScalaModule { + def scalaVersion = "3.3.1" +} + +object app extends ScalaModule { + def scalaVersion = "3.3.1" + def moduleDeps = Seq(lib) + + object test extends ScalaTests { + def ivyDeps = Seq(ivy"com.lihaoyi::utest:0.8.5") + def testFramework = "utest.runner.Framework" + def moduleDeps = Seq(app) + } +} diff --git a/integration/feature/test-quick/resources/lib/src/Combinator.scala b/integration/feature/test-quick/resources/lib/src/Combinator.scala new file mode 100644 index 000000000000..c19fd7c63ad9 --- /dev/null +++ b/integration/feature/test-quick/resources/lib/src/Combinator.scala @@ -0,0 +1,6 @@ +package lib + +trait Combinator[T] { + def combine(a: T, b: T): T + def combine2(a: T, b: T, c: T): T = combine(combine(a, b), c) +} diff --git a/integration/feature/test-quick/resources/lib/src/DefaultValue.scala b/integration/feature/test-quick/resources/lib/src/DefaultValue.scala new file mode 100644 index 000000000000..17969fcc2a23 --- /dev/null +++ b/integration/feature/test-quick/resources/lib/src/DefaultValue.scala @@ -0,0 +1,5 @@ +package lib + +trait DefaultValue[T] { + def defaultValue: T +} diff --git a/integration/feature/test-quick/src/TestQuickTests.scala b/integration/feature/test-quick/src/TestQuickTests.scala new file mode 100644 index 000000000000..148744859083 --- /dev/null +++ b/integration/feature/test-quick/src/TestQuickTests.scala @@ -0,0 +1,122 @@ +package mill.integration + +import mill.testkit.UtestIntegrationTestSuite + +import utest._ + +object TestQuickTests extends UtestIntegrationTestSuite { + val tests: Tests = Tests { + test("update app file") - integrationTest { tester => + import tester._ + + // First run, all tests should run + val firstRun = eval("app.test.testQuick") + val firstRunOutLines = firstRun.out.linesIterator.toSeq + Seq( + "app.MyNumberCombinatorTests.simple", + "app.MyStringCombinatorTests.simple", + "app.MyStringDefaultValueTests.simple", + "app.MyNumberDefaultValueTests.simple" + ).foreach { expectedLines => + val exists = firstRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Second run, nothing should run because we're not changing anything + val secondRun = eval("app.test.testQuick") + assert(secondRun.out.isEmpty) + + // Third run, MyNumber.scala changed, so MyNumberDefaultValueTests should run + modifyFile( + workspacePath / "app" / "src" / "MyNumber.scala", + _.replace( + "def defaultValue: MyNumber = MyNumber(0)", + "def defaultValue: MyNumber = MyNumber(1)" + ) + ) + val thirdRun = eval("app.test.testQuick") + val thirdRunOutLines = thirdRun.out.linesIterator.toSeq + Seq( + "app.MyNumberDefaultValueTests.simple" + ).foreach { expectedLines => + val exists = thirdRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Fourth run, MyNumberDefaultValueTests was failed, so it should run again + val fourthRun = eval("app.test.testQuick") + val fourthRunOutLines = fourthRun.out.linesIterator.toSeq + Seq( + "app.MyNumberDefaultValueTests.simple" + ).foreach { expectedLines => + val exists = fourthRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Fifth run, MyNumberDefaultValueTests was fixed, so it should run again + modifyFile( + workspacePath / "app" / "test" / "src" / "MyNumberDefaultValueTests.scala", + _.replace("assert(result == MyNumber(0))", "assert(result == MyNumber(1))") + ) + val fifthRun = eval("app.test.testQuick") + val fifthRunOutLines = fifthRun.out.linesIterator.toSeq + Seq( + "app.MyNumberDefaultValueTests.simple" + ).foreach { expectedLines => + val exists = fifthRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Sixth run, nothing should run because we're not changing anything + val sixthRun = eval("app.test.testQuick") + assert(sixthRun.out.isEmpty) + } + test("update lib file") - integrationTest { tester => + import tester._ + + // First run, all tests should run + val firstRun = eval("app.test.testQuick") + val firstRunOutLines = firstRun.out.linesIterator.toSeq + Seq( + "app.MyNumberCombinatorTests.simple", + "app.MyStringCombinatorTests.simple", + "app.MyStringDefaultValueTests.simple", + "app.MyNumberDefaultValueTests.simple" + ).foreach { expectedLines => + val exists = firstRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Second run, nothing should run because we're not changing anything + val secondRun = eval("app.test.testQuick") + assert(secondRun.out.isEmpty) + + // Third run, Combinator.scala changed syntactically, should not run + modifyFile( + workspacePath / "lib" / "src" / "Combinator.scala", + _.replace("def combine(a: T, b: T): T", "def combine(b: T, a: T): T") + ) + val thirdRun = eval("app.test.testQuick") + assert(thirdRun.out.isEmpty) + + // Fourth run, Combinator.scala changed sematically, should run MyNumberCombinatorTests & MyStringCombinatorTests + modifyFile( + workspacePath / "lib" / "src" / "Combinator.scala", + _.replace("def combine2(a: T, b: T, c: T): T = combine(combine(a, b), c)", "def combine2(a: T, b: T, c: T): T = combine(a, combine(b, c))") + ) + val fourthRun = eval("app.test.testQuick") + val fourthRunOutLines = fourthRun.out.linesIterator.toSeq + Seq( + "app.MyNumberCombinatorTests.simple", + "app.MyStringCombinatorTests.simple" + ).foreach { expectedLines => + val exists = fourthRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Fifth run, nothing should run because we're not changing anything + val fifthRun = eval("app.test.testQuick") + assert(fifthRun.out.isEmpty) + } + } +} diff --git a/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala b/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala index 4dcc33accafb..9eed2e0d8f33 100644 --- a/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala +++ b/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala @@ -20,6 +20,8 @@ import mill.define.Target import mill.api.Watchable import mill.api.internal.internal import mill.runner.worker.api.MillScalaParser +import mill.codesig.JvmModel.MethodDef +import mill.codesig.JvmModel.MethodSig import scala.collection.mutable @@ -157,74 +159,76 @@ class MillBuildRootModule()(implicit } } + @internal + override protected def callGraphAnalysisIgnoreCalls(callSiteOpt: Option[MethodDef], calledSig: MethodSig): Boolean = { + // We can ignore all calls to methods that look like Targets when traversing + // the call graph. We can do this because we assume `def` Targets are pure, + // and so any changes in their behavior will be picked up by the runtime build + // graph evaluator without needing to be accounted for in the post-compile + // bytecode callgraph analysis. + def isSimpleTarget(desc: mill.codesig.JvmModel.Desc) = + (desc.ret.pretty == classOf[mill.define.Target[?]].getName || + desc.ret.pretty == classOf[mill.define.Worker[?]].getName) && + desc.args.isEmpty + + // We avoid ignoring method calls that are simple trait forwarders, because + // we need the trait forwarders calls to be counted in order to wire up the + // method definition that a Target is associated with during evaluation + // (e.g. `myModuleObject.myTarget`) with its implementation that may be defined + // somewhere else (e.g. `trait MyModuleTrait{ def myTarget }`). Only that one + // step is necessary, after that the runtime build graph invalidation logic can + // take over + def isForwarderCallsiteOrLambda = + callSiteOpt.nonEmpty && { + val callSiteSig = callSiteOpt.get.sig + + (callSiteSig.name == (calledSig.name + "$") && + callSiteSig.static && + callSiteSig.desc.args.size == 1) + || ( + // In Scala 3, lambdas are implemented by private instance methods, + // not static methods, so they fall through the crack of "isSimpleTarget". + // Here make the assumption that a zero-arg lambda called from a simpleTarget, + // should in fact be tracked. e.g. see `integration.invalidation[codesig-hello]`, + // where the body of the `def foo` target is a zero-arg lambda i.e. the argument + // of `Cacher.cachedTarget`. + // To be more precise I think ideally we should capture more information in the signature + isSimpleTarget(callSiteSig.desc) && calledSig.name.contains("$anonfun") + ) + } + + // We ignore Commands for the same reason as we ignore Targets, and also because + // their implementations get gathered up all the via the `Discover` macro, but this + // is primarily for use as external entrypoints and shouldn't really be counted as + // part of the `millbuild.build#` transitive call graph they would normally + // be counted as + def isCommand = + calledSig.desc.ret.pretty == classOf[mill.define.Command[?]].getName + + // Skip calls to `millDiscover`. `millDiscover` is bundled as part of `RootModule` for + // convenience, but it should really never be called by any normal Mill module/task code, + // and is only used by downstream code in `mill.eval`/`mill.resolve`. Thus although CodeSig's + // conservative analysis considers potential calls from `build_.package_$#` to + // `millDiscover()`, we can safely ignore that possibility + def isMillDiscover = + calledSig.name == "millDiscover$lzyINIT1" || + calledSig.name == "millDiscover" || + callSiteOpt.exists(_.sig.name == "millDiscover") + + (isSimpleTarget(calledSig.desc) && !isForwarderCallsiteOrLambda) || isCommand || isMillDiscover + } + def codeSignatures: T[Map[String, Int]] = Task(persistent = true) { os.remove.all(Task.dest / "previous") - if (os.exists(Task.dest / "current")) - os.move.over(Task.dest / "current", Task.dest / "previous") + if (os.exists(Task.dest / "current")) os.move.over(Task.dest / "current", Task.dest / "previous") + val debugEnabled = Task.log.debugEnabled - val codesig = mill.codesig.CodeSig + + val callAnalysis = mill.codesig.CodeSig .compute( classFiles = os.walk(compile().classes.path).filter(_.ext == "class"), upstreamClasspath = compileClasspath().toSeq.map(_.path), - ignoreCall = { (callSiteOpt, calledSig) => - // We can ignore all calls to methods that look like Targets when traversing - // the call graph. We can do this because we assume `def` Targets are pure, - // and so any changes in their behavior will be picked up by the runtime build - // graph evaluator without needing to be accounted for in the post-compile - // bytecode callgraph analysis. - def isSimpleTarget(desc: mill.codesig.JvmModel.Desc) = - (desc.ret.pretty == classOf[mill.define.Target[?]].getName || - desc.ret.pretty == classOf[mill.define.Worker[?]].getName) && - desc.args.isEmpty - - // We avoid ignoring method calls that are simple trait forwarders, because - // we need the trait forwarders calls to be counted in order to wire up the - // method definition that a Target is associated with during evaluation - // (e.g. `myModuleObject.myTarget`) with its implementation that may be defined - // somewhere else (e.g. `trait MyModuleTrait{ def myTarget }`). Only that one - // step is necessary, after that the runtime build graph invalidation logic can - // take over - def isForwarderCallsiteOrLambda = - callSiteOpt.nonEmpty && { - val callSiteSig = callSiteOpt.get.sig - - (callSiteSig.name == (calledSig.name + "$") && - callSiteSig.static && - callSiteSig.desc.args.size == 1) - || ( - // In Scala 3, lambdas are implemented by private instance methods, - // not static methods, so they fall through the crack of "isSimpleTarget". - // Here make the assumption that a zero-arg lambda called from a simpleTarget, - // should in fact be tracked. e.g. see `integration.invalidation[codesig-hello]`, - // where the body of the `def foo` target is a zero-arg lambda i.e. the argument - // of `Cacher.cachedTarget`. - // To be more precise I think ideally we should capture more information in the signature - isSimpleTarget(callSiteSig.desc) && calledSig.name.contains("$anonfun") - ) - } - - // We ignore Commands for the same reason as we ignore Targets, and also because - // their implementations get gathered up all the via the `Discover` macro, but this - // is primarily for use as external entrypoints and shouldn't really be counted as - // part of the `millbuild.build#` transitive call graph they would normally - // be counted as - def isCommand = - calledSig.desc.ret.pretty == classOf[mill.define.Command[?]].getName - - // Skip calls to `millDiscover`. `millDiscover` is bundled as part of `RootModule` for - // convenience, but it should really never be called by any normal Mill module/task code, - // and is only used by downstream code in `mill.eval`/`mill.resolve`. Thus although CodeSig's - // conservative analysis considers potential calls from `build_.package_$#` to - // `millDiscover()`, we can safely ignore that possibility - def isMillDiscover = - calledSig.name == "millDiscover$lzyINIT1" || - calledSig.name == "millDiscover" || - callSiteOpt.exists(_.sig.name == "millDiscover") - - (isSimpleTarget(calledSig.desc) && !isForwarderCallsiteOrLambda) || - isCommand || - isMillDiscover - }, + ignoreCall = (callSiteOpt, calledSig) => callGraphAnalysisIgnoreCalls(callSiteOpt, calledSig), logger = new mill.codesig.Logger( Task.dest / "current", Option.when(debugEnabled)(Task.dest / "current") @@ -236,8 +240,8 @@ class MillBuildRootModule()(implicit ) ) ) - - codesig.transitiveCallGraphHashes + + callAnalysis.transitiveCallGraphHashes } override def sources: T[Seq[PathRef]] = Task { diff --git a/scalalib/package.mill b/scalalib/package.mill index d5db885c9de3..2d08cd201a3a 100644 --- a/scalalib/package.mill +++ b/scalalib/package.mill @@ -16,7 +16,7 @@ import mill.define.Cross object `package` extends RootModule with build.MillStableScalaModule { - def moduleDeps = Seq(build.core.util, build.scalalib.api, build.testrunner) + def moduleDeps = Seq(build.core.util, build.scalalib.api, build.testrunner, build.core.codesig) def mvnDeps = { Agg(build.Deps.scalaXml) ++ { // despite compiling with Scala 3, we need to include scala-reflect diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala index 03dade3ed153..b344d2729e39 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -43,6 +43,8 @@ import mill.scalalib.publish.Artifact import mill.util.JarManifest import mill.util.Jvm import os.Path +import scala.util.Try +import scala.annotation.unused /** * Core configuration required to compile a single Java compilation target @@ -63,6 +65,7 @@ trait JavaModule override def jvmWorker: ModuleRef[JvmWorkerModule] = super.jvmWorker trait JavaTests extends JavaModule with TestModule { + import mill.testrunner.TestResult // Run some consistence checks hierarchyChecks() @@ -117,6 +120,109 @@ trait JavaModule case _: ClassNotFoundException => // if we can't find the classes, we certainly are not in a ScalaJSModule } } + + @internal + override protected def callGraphAnalysisClasspath: Task[Seq[os.Path]] = Task.Anon { + (upstreamCompileOutput().map(_.classes.path) :+ compile().classes.path).distinct + } + + @internal + override protected def callGraphAnalysisUpstreamClasspath: Task[Seq[os.Path]] = Task.Anon { + (Task.traverse(transitiveModuleCompileModuleDeps)(_.compileClasspath)().flatten.map(_.path) ++ compileClasspath().map(_.path)).distinct + } + + def testQuick(args: String*): Command[(String, Seq[TestResult])] = Task.Command(persistent = true) { + val quicktestFailedClassesLog = Task.dest / "quickTestFailedClasses.json" + val transitiveCallGraphHashes0 = Task.dest / "transitiveCallGraphHashes0.json" + val invalidatedClassNamesLog = Task.dest / "invalidatedClassNames.json" + + val classFiles: Seq[os.Path] = callGraphAnalysisClasspath() + .flatMap(os.walk(_).filter(_.ext == "class")) + .distinct + + val callAnalysis = mill.codesig.CodeSig + .getCallGraphAnalysis( + classFiles = classFiles, + upstreamClasspath = callGraphAnalysisUpstreamClasspath(), + ignoreCall = (callSiteOpt, calledSig) => callGraphAnalysisIgnoreCalls(callSiteOpt, calledSig) + ) + val testClasses = testForkGrouping() + val (quickTestClassLists, invalidatedClassNames) = if (!os.exists(transitiveCallGraphHashes0)) { + // cannot calcuate invalid classes, so test all classes + testClasses -> Set.empty[String] + } else { + val failedTestClasses = + if (!os.exists(quicktestFailedClassesLog)) { + Set.empty[String] + } else { + Try { + upickle.default.read[Seq[String]](os.read.stream(quicktestFailedClassesLog)) + }.getOrElse(Seq.empty[String]).toSet + } + + val invalidatedClassNames = callAnalysis.calculateInvalidatedClassNames { + Some(upickle.default.read[Map[String, Int]](os.read.stream(transitiveCallGraphHashes0))) + } + + val testingClasses = invalidatedClassNames ++ failedTestClasses + + testClasses + .map(_.filter(testingClasses.contains)) + .filter(_.nonEmpty) -> invalidatedClassNames + } + + // Clean up the directory for test runners + os.walk(Task.dest).foreach { subPath => os.remove.all(subPath) } + + val quickTestReportXml = testReportXml() + + val testModuleUtil = new TestModuleUtil( + testUseArgsFile(), + forkArgs(), + Seq.empty, + jvmWorker().scalalibClasspath(), + resources(), + testFramework(), + runClasspath(), + testClasspath(), + args.toSeq, + quickTestClassLists, + jvmWorker().testrunnerEntrypointClasspath(), + forkEnv(), + testSandboxWorkingDir(), + forkWorkingDir(), + quickTestReportXml, + jvmWorker().javaHome().map(_.path), + testParallelism(), + testLogLevel() + ) + + val results = testModuleUtil.runTests() + + val badTestClasses = (results match { + case Result.Failure(_) => + // Consider all quick testing classes as failed + quickTestClassLists.flatten + case Result.Success((_, results)) => + // Get all test classes that failed + results + .filter(testResult => Set("Error", "Failure").contains(testResult.status)) + .map(_.fullyQualifiedName) + }).distinct + + os.write.over(quicktestFailedClassesLog, upickle.default.write(badTestClasses)) + os.write.over(transitiveCallGraphHashes0, upickle.default.write(callAnalysis.transitiveCallGraphHashes0)) + os.write.over(invalidatedClassNamesLog, upickle.default.write(invalidatedClassNames)) + + results match { + case Result.Failure(errMsg) => Result.Failure(errMsg) + case Result.Success((doneMsg, results)) => + try TestModule.handleResults(doneMsg, results, Task.ctx(), quickTestReportXml) + catch { + case e: Throwable => Result.Failure("Test reporting failed: " + e) + } + } + } } def defaultCommandName(): String = "run" @@ -1527,6 +1633,22 @@ trait JavaModule } } + + @internal + protected def callGraphAnalysisIgnoreCalls( + @unused callSiteOpt: Option[mill.codesig.JvmModel.MethodDef], + @unused calledSig: mill.codesig.JvmModel.MethodSig + ): Boolean = false + + @internal + protected def callGraphAnalysisClasspath: Task[Seq[os.Path]] = Task.Anon { + Seq(compile().classes.path) + } + + @internal + protected def callGraphAnalysisUpstreamClasspath: Task[Seq[os.Path]] = Task.Anon { + compileClasspath().map(_.path) + } } object JavaModule { diff --git a/scalalib/src/mill/scalalib/TestModule.scala b/scalalib/src/mill/scalalib/TestModule.scala index 0b619f92fa2a..824c2b7f16e6 100644 --- a/scalalib/src/mill/scalalib/TestModule.scala +++ b/scalalib/src/mill/scalalib/TestModule.scala @@ -205,10 +205,12 @@ trait TestModule globSelectors: Task[Seq[String]] ): Task[(String, Seq[TestResult])] = Task.Anon { + val testGlobSelectors = globSelectors() + val reportXml = testReportXml() val testModuleUtil = new TestModuleUtil( testUseArgsFile(), forkArgs(), - globSelectors(), + testGlobSelectors, jvmWorker().scalalibClasspath(), resources(), testFramework(), @@ -220,12 +222,25 @@ trait TestModule forkEnv(), testSandboxWorkingDir(), forkWorkingDir(), - testReportXml(), + reportXml, jvmWorker().javaHome().map(_.path), testParallelism(), testLogLevel() ) - testModuleUtil.runTests() + val result = testModuleUtil.runTests() + + result match { + case Result.Failure(errMsg) => Result.Failure(errMsg) + case Result.Success((doneMsg, results)) => + if (results.isEmpty && testGlobSelectors.nonEmpty) throw new Result.Exception( + s"Test selector does not match any test: ${testGlobSelectors.mkString(" ")}" + + "\nRun discoveredTestClasses to see available tests" + ) + try TestModule.handleResults(doneMsg, results, Task.ctx(), reportXml) + catch { + case e: Throwable => Result.Failure("Test reporting failed: " + e) + } + } } /** diff --git a/scalalib/src/mill/scalalib/TestModuleUtil.scala b/scalalib/src/mill/scalalib/TestModuleUtil.scala index 1fd90b8ebd07..d04f1b394268 100644 --- a/scalalib/src/mill/scalalib/TestModuleUtil.scala +++ b/scalalib/src/mill/scalalib/TestModuleUtil.scala @@ -102,21 +102,11 @@ private final class TestModuleUtil( } if (selectors.nonEmpty && filteredClassLists.isEmpty) throw doesNotMatchError - val result = if (testParallelism) { + if (testParallelism) { runTestQueueScheduler(filteredClassLists) } else { runTestDefault(filteredClassLists) } - - result match { - case Result.Failure(errMsg) => Result.Failure(errMsg) - case Result.Success((doneMsg, results)) => - if (results.isEmpty && selectors.nonEmpty) throw doesNotMatchError - try TestModuleUtil.handleResults(doneMsg, results, Task.ctx(), testReportXml) - catch { - case e: Throwable => Result.Failure("Test reporting failed: " + e) - } - } } private def callTestRunnerSubprocess( diff --git a/website/docs/modules/ROOT/pages/kotlinlib/testing.adoc b/website/docs/modules/ROOT/pages/kotlinlib/testing.adoc index 3f7a10212c8e..5d058b23fa55 100644 --- a/website/docs/modules/ROOT/pages/kotlinlib/testing.adoc +++ b/website/docs/modules/ROOT/pages/kotlinlib/testing.adoc @@ -30,6 +30,10 @@ include::partial$example/kotlinlib/testing/5-test-grouping.adoc[] include::partial$example/kotlinlib/testing/6-test-group-parallel.adoc[] +== Fine-Grained Selective Testing with Test Quick + +include::partial$example/kotlinlib/testing/7-test-quick.adoc[] + == Github Actions Test Reports If you use Github Actions for CI, you can use https://github.com/mikepenz/action-junit-report in diff --git a/website/docs/modules/ROOT/pages/scalalib/testing.adoc b/website/docs/modules/ROOT/pages/scalalib/testing.adoc index 708bb7dff221..1095e8f1ae81 100644 --- a/website/docs/modules/ROOT/pages/scalalib/testing.adoc +++ b/website/docs/modules/ROOT/pages/scalalib/testing.adoc @@ -30,6 +30,10 @@ include::partial$example/scalalib/testing/5-test-grouping.adoc[] include::partial$example/scalalib/testing/6-test-group-parallel.adoc[] +== Fine-Grained Selective Testing with Test Quick + +include::partial$example/scalalib/testing/7-test-quick.adoc[] + == Github Actions Test Reports If you use Github Actions for CI, you can use https://github.com/mikepenz/action-junit-report in