From f66247f4c8b9a870d855f8d69e900e8e77ca0024 Mon Sep 17 00:00:00 2001 From: HollandDM Date: Sat, 15 Mar 2025 10:41:04 +0700 Subject: [PATCH 1/4] add testQuick command --- .../mill/codesig/ReachabilityAnalysis.scala | 45 ++++- .../runner/meta/MillBuildRootModule.scala | 87 +-------- scalalib/package.mill | 2 +- scalalib/src/mill/scalalib/JavaModule.scala | 171 +++++++++++++++++- scalalib/src/mill/scalalib/TestModule.scala | 21 ++- .../src/mill/scalalib/TestModuleUtil.scala | 12 +- 6 files changed, 235 insertions(+), 103 deletions(-) diff --git a/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala b/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala index 9172ff416372..97abd9fb0a29 100644 --- a/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala +++ b/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala @@ -2,10 +2,11 @@ package mill.codesig import mill.codesig.JvmModel.* import mill.internal.{SpanningForest, Tarjans} -import ujson.Obj +import ujson.{Arr, Obj} import upickle.default.{Writer, writer} import scala.collection.immutable.SortedMap +import scala.collection.mutable class CallGraphAnalysis( localSummary: LocalSummary, @@ -81,7 +82,7 @@ class CallGraphAnalysis( logger.mandatoryLog(transitiveCallGraphHashes0) logger.log(transitiveCallGraphHashes) - lazy val spanningInvalidationTree: Obj = prevTransitiveCallGraphHashesOpt() match { + lazy val (spanningInvalidationTree: Obj, invalidClassNames: Arr) = prevTransitiveCallGraphHashesOpt() match { case Some(prevTransitiveCallGraphHashes) => CallGraphAnalysis.spanningInvalidationTree( prevTransitiveCallGraphHashes, @@ -89,10 +90,11 @@ class CallGraphAnalysis( indexToNodes, indexGraphEdges ) - case None => ujson.Obj() + case None => ujson.Obj() -> ujson.Arr() } logger.mandatoryLog(spanningInvalidationTree) + logger.mandatoryLog(invalidClassNames) } object CallGraphAnalysis { @@ -115,7 +117,7 @@ object CallGraphAnalysis { transitiveCallGraphHashes0: Array[(CallGraphAnalysis.Node, Int)], indexToNodes: Array[Node], indexGraphEdges: Array[Array[Int]] - ): ujson.Obj = { + ): (ujson.Obj, ujson.Arr) = { val transitiveCallGraphHashes0Map = transitiveCallGraphHashes0.toMap val nodesWithChangedHashes = indexGraphEdges @@ -135,10 +137,23 @@ object CallGraphAnalysis { val reverseGraphEdges = indexGraphEdges.indices.map(reverseGraphMap.getOrElse(_, Array[Int]())).toArray - SpanningForest.spanningTreeToJsonTree( - SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false), + val spanningForest = SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false) + + val spanningInvalidationTree = SpanningForest.spanningTreeToJsonTree( + spanningForest, k => indexToNodes(k).toString ) + + val invalidSet = invalidClassNameSet( + spanningForest, + indexToNodes.map { + case LocalDef(call) => call.cls.name + case Call(call) => call.cls.name + case ExternalClsCall(cls) => cls.name + } + ) + + (spanningInvalidationTree, invalidSet) } def indexGraphEdges( @@ -263,6 +278,24 @@ object CallGraphAnalysis { } } + private def invalidClassNameSet( + spanningForest: SpanningForest.Node, + indexToClassName: Array[String] + ): Set[String] = { + val queue = mutable.ArrayBuffer.empty[(Int, SpanningForest.Node)] + val result = mutable.Set.empty[String] + + queue.appendAll(spanningForest.values) + + while (queue.nonEmpty) { + val (index, node) = queue.remove(0) + result += indexToClassName(index) + queue.appendAll(node.values) + } + + result.toSet + } + /** * Represents the three types of nodes in our call graph. These are kept heterogeneous * because flattening them out into a homogenous graph of MethodDef -> MethodDef edges diff --git a/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala b/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala index 4dcc33accafb..42f473f5d98f 100644 --- a/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala +++ b/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala @@ -157,87 +157,12 @@ class MillBuildRootModule()(implicit } } - 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") - val debugEnabled = Task.log.debugEnabled - val codesig = 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 - }, - logger = new mill.codesig.Logger( - Task.dest / "current", - Option.when(debugEnabled)(Task.dest / "current") - ), - prevTransitiveCallGraphHashesOpt = () => - Option.when(os.exists(Task.dest / "previous/transitiveCallGraphHashes0.json"))( - upickle.default.read[Map[String, Int]]( - os.read.stream(Task.dest / "previous/transitiveCallGraphHashes0.json") - ) - ) - ) - - codesig.transitiveCallGraphHashes + def codeSignatures: T[Map[String, Int]] = Task { + val (analysisFolder, _) = callGraphAnalysis() + val transitiveCallGraphHashes0 = upickle.default.read[Map[String, Int]]( + os.read.stream(analysisFolder / "transitiveCallGraphHashes0.json") + ) + transitiveCallGraphHashes0 } 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..abb354335104 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -43,7 +43,7 @@ import mill.scalalib.publish.Artifact import mill.util.JarManifest import mill.util.Jvm import os.Path - +import scala.util.Try /** * Core configuration required to compile a single Java compilation target */ @@ -63,6 +63,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 +118,90 @@ trait JavaModule case _: ClassNotFoundException => // if we can't find the classes, we certainly are not in a ScalaJSModule } } + + private def quickTest(args: Seq[String]): Task[(String, Seq[TestResult])] = + Task(persistent = true) { + val quicktestFailedClassesLog = Task.dest / "quickTestFailedClasses.log" + val (analysisFolder, previousAnalysisFolderOpt) = callGraphAnalysis() + val globFilter: String => Boolean = { + previousAnalysisFolderOpt.fold { + // no previous analysis folder, we accept all classes + TestRunnerUtils.globFilter(Seq.empty) + } { _ => + + val failedTestClasses = + if (!os.exists(quicktestFailedClassesLog)) { + Seq.empty[String] + } else { + Try { + upickle.default.read[Seq[String]](os.read.stream(quicktestFailedClassesLog)) + }.getOrElse(Seq.empty[String]) + } + + val quiteTestingClasses = + Try { + upickle.default.read[Seq[String]](os.read.stream(analysisFolder / "invalidClassNames.json")) + }.getOrElse(Seq.empty[String]) + + TestRunnerUtils.globFilter((failedTestClasses ++ quiteTestingClasses).distinct) + } + } + + // Clean yp the directory + os.walk(Task.dest).foreach { subPath => os.remove.all(subPath) } + + val quickTestClassLists = testForkGrouping().map(_.filter(globFilter)).filter(_.nonEmpty) + + val quickTestReportXml = testReportXml() + + val testModuleUtil = new TestModuleUtil( + testUseArgsFile(), + forkArgs(), + Seq.empty, + zincWorker().scalalibClasspath(), + resources(), + testFramework(), + runClasspath(), + testClasspath(), + args.toSeq, + quickTestClassLists, + zincWorker().testrunnerEntrypointClasspath(), + forkEnv(), + testSandboxWorkingDir(), + forkWorkingDir(), + quickTestReportXml, + zincWorker().javaHome().map(_.path), + testParallelism() + ) + + 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) + } + + os.write.over(quicktestFailedClassesLog, upickle.default.write[Seq[String]](badTestClasses.distinct)) + + 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 testQuick(args: String*): Command[(String, Seq[TestResult])] = Task.Command { + quickTest(args.toSeq)() + } } def defaultCommandName(): String = "run" @@ -1527,6 +1612,90 @@ trait JavaModule } } + + // Return the directory containing the current call graph analysis results, and previous one too if it exists + def callGraphAnalysis: T[(Path, Option[Path])] = Task(persistent = true) { + os.remove.all(Task.dest / "previous") + if (os.exists(Task.dest / "current")) + os.move.over(Task.dest / "current", Task.dest / "previous") + val debugEnabled = Task.log.debugEnabled + 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 + }, + logger = new mill.codesig.Logger( + Task.dest / "current", + Option.when(debugEnabled)(Task.dest / "current") + ), + prevTransitiveCallGraphHashesOpt = () => + Option.when(os.exists(Task.dest / "previous/transitiveCallGraphHashes0.json"))( + upickle.default.read[Map[String, Int]]( + os.read.stream(Task.dest / "previous/transitiveCallGraphHashes0.json") + ) + ) + ) + + (Task.dest / "current", Option.when(os.exists(Task.dest / "previous"))(Task.dest / "previous")) + } } 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( From c3051a2ac2ca579a0b378a67e36475af5caed235 Mon Sep 17 00:00:00 2001 From: HollandDM Date: Mon, 17 Mar 2025 13:40:27 +0700 Subject: [PATCH 2/4] update update 2 update 3 --- core/codesig/src/mill/codesig/CodeSig.scala | 60 +++- .../src/mill/codesig/ExternalSummary.scala | 6 +- .../mill/codesig/ReachabilityAnalysis.scala | 153 +++++---- core/define/src/mill/define/Task.scala | 21 +- .../runner/meta/MillBuildRootModule.scala | 91 ++++- scalalib/src/mill/scalalib/JavaModule.scala | 318 ++++++++---------- 6 files changed, 369 insertions(+), 280 deletions(-) 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 97abd9fb0a29..2909ddc1f452 100644 --- a/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala +++ b/core/codesig/src/mill/codesig/ReachabilityAnalysis.scala @@ -2,19 +2,17 @@ package mill.codesig import mill.codesig.JvmModel.* import mill.internal.{SpanningForest, Tarjans} -import ujson.{Arr, 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 { @@ -41,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)) @@ -50,8 +46,6 @@ class CallGraphAnalysis( .to(SortedMap) } - logger.mandatoryLog(prettyCallGraph) - def transitiveCallGraphValues[V: scala.reflect.ClassTag]( nodeValues: Array[V], reduce: (V, V) => V, @@ -79,45 +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, invalidClassNames: Arr) = prevTransitiveCallGraphHashesOpt() match { - case Some(prevTransitiveCallGraphHashes) => - CallGraphAnalysis.spanningInvalidationTree( - prevTransitiveCallGraphHashes, - transitiveCallGraphHashes0, - indexToNodes, - indexGraphEdges - ) - case None => ujson.Obj() -> ujson.Arr() + 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) - logger.mandatoryLog(invalidClassNames) + 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, ujson.Arr) = { + ) = { val transitiveCallGraphHashes0Map = transitiveCallGraphHashes0.toMap val nodesWithChangedHashes = indexGraphEdges @@ -137,23 +131,62 @@ object CallGraphAnalysis { val reverseGraphEdges = indexGraphEdges.indices.map(reverseGraphMap.getOrElse(_, Array[Int]())).toArray - val spanningForest = SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false) + SpanningForest.apply(reverseGraphEdges, nodesWithChangedHashes, false) + } - val spanningInvalidationTree = SpanningForest.spanningTreeToJsonTree( - spanningForest, + /** + * 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( + getSpanningForest(prevTransitiveCallGraphHashes, transitiveCallGraphHashes0, indexToNodes, indexGraphEdges), k => indexToNodes(k).toString ) + } - val invalidSet = invalidClassNameSet( - spanningForest, - indexToNodes.map { - case LocalDef(call) => call.cls.name - case Call(call) => call.cls.name - case ExternalClsCall(cls) => cls.name + /** + * 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) + } + } - (spanningInvalidationTree, invalidSet) + builder.result() } def indexGraphEdges( @@ -278,24 +311,6 @@ object CallGraphAnalysis { } } - private def invalidClassNameSet( - spanningForest: SpanningForest.Node, - indexToClassName: Array[String] - ): Set[String] = { - val queue = mutable.ArrayBuffer.empty[(Int, SpanningForest.Node)] - val result = mutable.Set.empty[String] - - queue.appendAll(spanningForest.values) - - while (queue.nonEmpty) { - val (index, node) = queue.remove(0) - result += indexToClassName(index) - queue.appendAll(node.values) - } - - result.toSet - } - /** * Represents the three types of nodes in our call graph. These are kept heterogeneous * because flattening them out into a homogenous graph of MethodDef -> MethodDef edges 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/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala b/runner/meta/src/mill/runner/meta/MillBuildRootModule.scala index 42f473f5d98f..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,12 +159,89 @@ class MillBuildRootModule()(implicit } } - def codeSignatures: T[Map[String, Int]] = Task { - val (analysisFolder, _) = callGraphAnalysis() - val transitiveCallGraphHashes0 = upickle.default.read[Map[String, Int]]( - os.read.stream(analysisFolder / "transitiveCallGraphHashes0.json") - ) - transitiveCallGraphHashes0 + @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") + + val debugEnabled = Task.log.debugEnabled + + val callAnalysis = mill.codesig.CodeSig + .compute( + classFiles = os.walk(compile().classes.path).filter(_.ext == "class"), + upstreamClasspath = compileClasspath().toSeq.map(_.path), + ignoreCall = (callSiteOpt, calledSig) => callGraphAnalysisIgnoreCalls(callSiteOpt, calledSig), + logger = new mill.codesig.Logger( + Task.dest / "current", + Option.when(debugEnabled)(Task.dest / "current") + ), + prevTransitiveCallGraphHashesOpt = () => + Option.when(os.exists(Task.dest / "previous/transitiveCallGraphHashes0.json"))( + upickle.default.read[Map[String, Int]]( + os.read.stream(Task.dest / "previous/transitiveCallGraphHashes0.json") + ) + ) + ) + + callAnalysis.transitiveCallGraphHashes } override def sources: T[Seq[PathRef]] = Task { diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala index abb354335104..3d62068e5711 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -11,31 +11,23 @@ import coursier.core.Resolution import coursier.params.ResolutionParams import coursier.parse.JavaOrScalaModule import coursier.parse.ModuleParser -import coursier.util.EitherT -import coursier.util.ModuleMatcher -import coursier.util.Monad -import mainargs.Flag -import mill.api.MillException -import mill.api.Result -import mill.api.Segments -import mill.api.internal.BspBuildTarget -import mill.api.internal.BspModuleApi -import mill.api.internal.BspUri -import mill.api.internal.EvaluatorApi -import mill.api.internal.IdeaConfigFile -import mill.api.internal.JavaFacet -import mill.api.internal.JavaModuleApi -import mill.api.internal.JvmBuildTarget -import mill.api.internal.ResolvedModule -import mill.api.internal.Scoped -import mill.api.internal.internal -import mill.define.Command -import mill.define.ModuleRef -import mill.define.PathRef -import mill.define.Segment -import mill.define.Task -import mill.define.TaskCtx -import mill.define.TaskModule +import coursier.util.{EitherT, ModuleMatcher, Monad} +import coursier.{Repository, Type} +import mainargs.{Flag, arg} +import mill.util.JarManifest +import mill.api.{MillException, Result, Segments} +import mill.api.internal.{ + BspBuildTarget, + EvaluatorApi, + IdeaConfigFile, + JavaFacet, + ResolvedModule, + Scoped, + internal +} +import mill.define.{TaskCtx, PathRef} +import mill.define.{Command, ModuleRef, Segment, Task, TaskModule} +import mill.scalalib.internal.ModuleUtils import mill.scalalib.api.CompilationResult import mill.scalalib.bsp.BspModule import mill.scalalib.internal.ModuleUtils @@ -44,6 +36,8 @@ 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 */ @@ -119,88 +113,107 @@ trait JavaModule } } - private def quickTest(args: Seq[String]): Task[(String, Seq[TestResult])] = - Task(persistent = true) { - val quicktestFailedClassesLog = Task.dest / "quickTestFailedClasses.log" - val (analysisFolder, previousAnalysisFolderOpt) = callGraphAnalysis() - val globFilter: String => Boolean = { - previousAnalysisFolderOpt.fold { - // no previous analysis folder, we accept all classes - TestRunnerUtils.globFilter(Seq.empty) - } { _ => - - val failedTestClasses = - if (!os.exists(quicktestFailedClassesLog)) { - Seq.empty[String] - } else { - Try { - upickle.default.read[Seq[String]](os.read.stream(quicktestFailedClassesLog)) - }.getOrElse(Seq.empty[String]) - } - - val quiteTestingClasses = - Try { - upickle.default.read[Seq[String]](os.read.stream(analysisFolder / "invalidClassNames.json")) - }.getOrElse(Seq.empty[String]) - - TestRunnerUtils.globFilter((failedTestClasses ++ quiteTestingClasses).distinct) - } - } + @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 + } - // Clean yp the directory - os.walk(Task.dest).foreach { subPath => os.remove.all(subPath) } - - val quickTestClassLists = testForkGrouping().map(_.filter(globFilter)).filter(_.nonEmpty) - - val quickTestReportXml = testReportXml() - - val testModuleUtil = new TestModuleUtil( - testUseArgsFile(), - forkArgs(), - Seq.empty, - zincWorker().scalalibClasspath(), - resources(), - testFramework(), - runClasspath(), - testClasspath(), - args.toSeq, - quickTestClassLists, - zincWorker().testrunnerEntrypointClasspath(), - forkEnv(), - testSandboxWorkingDir(), - forkWorkingDir(), - quickTestReportXml, - zincWorker().javaHome().map(_.path), - testParallelism() + 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 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) - } - - os.write.over(quicktestFailedClassesLog, upickle.default.write[Seq[String]](badTestClasses.distinct)) - - 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) - } + 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 } - def testQuick(args: String*): Command[(String, Seq[TestResult])] = Task.Command { - quickTest(args.toSeq)() + // 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) + } + } } } @@ -1612,89 +1625,26 @@ trait JavaModule } } - - // Return the directory containing the current call graph analysis results, and previous one too if it exists - def callGraphAnalysis: T[(Path, Option[Path])] = Task(persistent = true) { - os.remove.all(Task.dest / "previous") - if (os.exists(Task.dest / "current")) - os.move.over(Task.dest / "current", Task.dest / "previous") - val debugEnabled = Task.log.debugEnabled - 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 - }, - logger = new mill.codesig.Logger( - Task.dest / "current", - Option.when(debugEnabled)(Task.dest / "current") - ), - prevTransitiveCallGraphHashesOpt = () => - Option.when(os.exists(Task.dest / "previous/transitiveCallGraphHashes0.json"))( - upickle.default.read[Map[String, Int]]( - os.read.stream(Task.dest / "previous/transitiveCallGraphHashes0.json") - ) - ) - ) - - (Task.dest / "current", Option.when(os.exists(Task.dest / "previous"))(Task.dest / "previous")) + + def buildLibraryPaths: Task[Seq[java.nio.file.Path]] = Task.Anon { + Lib.resolveMillBuildDeps(allRepositories(), Option(Task.ctx()), useSources = true) + Lib.resolveMillBuildDeps(allRepositories(), Option(Task.ctx()), useSources = false).map(_.toNIO) + } + + @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) } } From e156bc1c2403720f53881281a0c5b999e83acb16 Mon Sep 17 00:00:00 2001 From: HollandDM Date: Fri, 11 Apr 2025 21:16:10 +0700 Subject: [PATCH 3/4] add integration tests --- .../resources/app/src/MyNumber.scala | 21 +++ .../resources/app/src/MyString.scala | 21 +++ .../test/src/MyNumberCombinatorTests.scala | 16 +++ .../test/src/MyNumberDefaultValueTests.scala | 13 ++ .../test/src/MyStringCombinatorTests.scala | 16 +++ .../test/src/MyStringDefaultValueTests.scala | 13 ++ .../feature/test-quick/resources/build.mill | 18 +++ .../resources/lib/src/Combinator.scala | 6 + .../resources/lib/src/DefaultValue.scala | 5 + .../test-quick/src/TestQuickTests.scala | 122 ++++++++++++++++++ 10 files changed, 251 insertions(+) create mode 100644 integration/feature/test-quick/resources/app/src/MyNumber.scala create mode 100644 integration/feature/test-quick/resources/app/src/MyString.scala create mode 100644 integration/feature/test-quick/resources/app/test/src/MyNumberCombinatorTests.scala create mode 100644 integration/feature/test-quick/resources/app/test/src/MyNumberDefaultValueTests.scala create mode 100644 integration/feature/test-quick/resources/app/test/src/MyStringCombinatorTests.scala create mode 100644 integration/feature/test-quick/resources/app/test/src/MyStringDefaultValueTests.scala create mode 100644 integration/feature/test-quick/resources/build.mill create mode 100644 integration/feature/test-quick/resources/lib/src/Combinator.scala create mode 100644 integration/feature/test-quick/resources/lib/src/DefaultValue.scala create mode 100644 integration/feature/test-quick/src/TestQuickTests.scala 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) + } + } +} From 7a50b88807fd528f8ff13ca3d1531b87052ed0cf Mon Sep 17 00:00:00 2001 From: HollandDM Date: Tue, 29 Apr 2025 16:47:57 +0700 Subject: [PATCH 4/4] add examples and adocs --- .../javalib/testing/7-test-quick/build.mill | 33 ++++++++ .../testing/7-test-quick/foo/src/Bar.java | 11 +++ .../testing/7-test-quick/foo/src/Foo.java | 15 ++++ .../7-test-quick/foo/test/src/FooTest1.java | 13 +++ .../7-test-quick/foo/test/src/FooTest2.java | 13 +++ .../kotlinlib/testing/7-test-quick/build.mill | 38 +++++++++ .../testing/7-test-quick/foo/src/Bar.kt | 11 +++ .../testing/7-test-quick/foo/src/Foo.kt | 16 ++++ .../7-test-quick/foo/test/src/FooTest1.kt | 13 +++ .../7-test-quick/foo/test/src/FooTest2.kt | 13 +++ .../scalalib/testing/7-test-quick/build.mill | 84 +++++++++++++++++++ .../testing/7-test-quick/foo/src/Bar.scala | 11 +++ .../testing/7-test-quick/foo/src/Foo.scala | 13 +++ .../7-test-quick/foo/test/src/FooTest1.scala | 11 +++ .../7-test-quick/foo/test/src/FooTest2.scala | 11 +++ scalalib/src/mill/scalalib/JavaModule.scala | 47 ++++++----- .../modules/ROOT/pages/kotlinlib/testing.adoc | 4 + .../modules/ROOT/pages/scalalib/testing.adoc | 4 + 18 files changed, 339 insertions(+), 22 deletions(-) create mode 100644 example/javalib/testing/7-test-quick/build.mill create mode 100644 example/javalib/testing/7-test-quick/foo/src/Bar.java create mode 100644 example/javalib/testing/7-test-quick/foo/src/Foo.java create mode 100644 example/javalib/testing/7-test-quick/foo/test/src/FooTest1.java create mode 100644 example/javalib/testing/7-test-quick/foo/test/src/FooTest2.java create mode 100644 example/kotlinlib/testing/7-test-quick/build.mill create mode 100644 example/kotlinlib/testing/7-test-quick/foo/src/Bar.kt create mode 100644 example/kotlinlib/testing/7-test-quick/foo/src/Foo.kt create mode 100644 example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest1.kt create mode 100644 example/kotlinlib/testing/7-test-quick/foo/test/src/FooTest2.kt create mode 100644 example/scalalib/testing/7-test-quick/build.mill create mode 100644 example/scalalib/testing/7-test-quick/foo/src/Bar.scala create mode 100644 example/scalalib/testing/7-test-quick/foo/src/Foo.scala create mode 100644 example/scalalib/testing/7-test-quick/foo/test/src/FooTest1.scala create mode 100644 example/scalalib/testing/7-test-quick/foo/test/src/FooTest2.scala 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/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala index 3d62068e5711..b344d2729e39 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -11,23 +11,31 @@ import coursier.core.Resolution import coursier.params.ResolutionParams import coursier.parse.JavaOrScalaModule import coursier.parse.ModuleParser -import coursier.util.{EitherT, ModuleMatcher, Monad} -import coursier.{Repository, Type} -import mainargs.{Flag, arg} -import mill.util.JarManifest -import mill.api.{MillException, Result, Segments} -import mill.api.internal.{ - BspBuildTarget, - EvaluatorApi, - IdeaConfigFile, - JavaFacet, - ResolvedModule, - Scoped, - internal -} -import mill.define.{TaskCtx, PathRef} -import mill.define.{Command, ModuleRef, Segment, Task, TaskModule} -import mill.scalalib.internal.ModuleUtils +import coursier.util.EitherT +import coursier.util.ModuleMatcher +import coursier.util.Monad +import mainargs.Flag +import mill.api.MillException +import mill.api.Result +import mill.api.Segments +import mill.api.internal.BspBuildTarget +import mill.api.internal.BspModuleApi +import mill.api.internal.BspUri +import mill.api.internal.EvaluatorApi +import mill.api.internal.IdeaConfigFile +import mill.api.internal.JavaFacet +import mill.api.internal.JavaModuleApi +import mill.api.internal.JvmBuildTarget +import mill.api.internal.ResolvedModule +import mill.api.internal.Scoped +import mill.api.internal.internal +import mill.define.Command +import mill.define.ModuleRef +import mill.define.PathRef +import mill.define.Segment +import mill.define.Task +import mill.define.TaskCtx +import mill.define.TaskModule import mill.scalalib.api.CompilationResult import mill.scalalib.bsp.BspModule import mill.scalalib.internal.ModuleUtils @@ -1626,11 +1634,6 @@ trait JavaModule } - def buildLibraryPaths: Task[Seq[java.nio.file.Path]] = Task.Anon { - Lib.resolveMillBuildDeps(allRepositories(), Option(Task.ctx()), useSources = true) - Lib.resolveMillBuildDeps(allRepositories(), Option(Task.ctx()), useSources = false).map(_.toNIO) - } - @internal protected def callGraphAnalysisIgnoreCalls( @unused callSiteOpt: Option[mill.codesig.JvmModel.MethodDef], 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