diff --git a/core/define/src/mill/define/Task.scala b/core/define/src/mill/define/Task.scala index 44d9eae3697a..29f0801f38c6 100644 --- a/core/define/src/mill/define/Task.scala +++ b/core/define/src/mill/define/Task.scala @@ -124,7 +124,13 @@ 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 }, '{ false }) } + + inline def Command[T](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 }) } /** * @param exclusive Exclusive commands run serially at the end of an evaluation, @@ -394,7 +400,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) @@ -546,7 +553,15 @@ private object TaskMacros { 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/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..e00b43356147 --- /dev/null +++ b/integration/feature/test-quick/resources/app/src/MyNumber.scala @@ -0,0 +1,24 @@ +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 = { + val temp = gCombinator.combine(a, b) + gCombinator.combine(temp, 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..e321cdbacd0c --- /dev/null +++ b/integration/feature/test-quick/resources/app/src/MyString.scala @@ -0,0 +1,24 @@ +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 = { + val temp = gCombinator.combine(a, b) + gCombinator.combine(temp, 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..27d67e57052a --- /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..22dc199dce72 --- /dev/null +++ b/integration/feature/test-quick/resources/lib/src/Combinator.scala @@ -0,0 +1,5 @@ +package lib + +trait Combinator[T] { + def combine(a: T, b: T): T +} 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..439c1674a52f --- /dev/null +++ b/integration/feature/test-quick/src/TestQuickTests.scala @@ -0,0 +1,115 @@ +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 & MyNumberCombinatorTests 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.MyNumberCombinatorTests.simple", + "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, so MyNumberCombinatorTests & MyStringCombinatorTests should 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") + val thirdRunOutLines = thirdRun.out.linesIterator.toSeq + Seq( + "app.MyNumberCombinatorTests.simple", + "app.MyStringCombinatorTests.simple" + ).foreach { expectedLines => + val exists = thirdRunOutLines.exists(_.contains(expectedLines)) + assert(exists) + } + + // Fourth run, nothing should run because we're not changing anything + val fourthRun = eval("app.test.testQuick") + assert(fourthRun.out.isEmpty) + } + } +} diff --git a/scalalib/api/src/mill/scalalib/api/TransitiveSourceStampResults.scala b/scalalib/api/src/mill/scalalib/api/TransitiveSourceStampResults.scala new file mode 100644 index 000000000000..92fd9005e477 --- /dev/null +++ b/scalalib/api/src/mill/scalalib/api/TransitiveSourceStampResults.scala @@ -0,0 +1,26 @@ +package mill.scalalib.api + +final case class TransitiveSourceStampResults( + currentStamps: Map[String, String], + previousStamps: Option[Map[String, String]] = None +) { + lazy val changedSources: Set[String] = { + previousStamps match { + case Some(prevStamps) => + currentStamps.view + .flatMap { (source, stamp) => + prevStamps.get(source) match { + case None => Some(source) // new source + case Some(prevStamp) => Option.when(stamp != prevStamp)(source) // changed source + } + } + .toSet + case None => currentStamps.keySet + } + } +} + +object TransitiveSourceStampResults { + implicit val jsonFormatter: upickle.default.ReadWriter[TransitiveSourceStampResults] = + upickle.default.macroRW +} diff --git a/scalalib/package.mill b/scalalib/package.mill index 9606f6a1683d..0b86249fe1ca 100644 --- a/scalalib/package.mill +++ b/scalalib/package.mill @@ -25,7 +25,8 @@ object `package` extends RootModule with build.MillStableScalaModule { // (also transitively included by com.eed3si9n.jarjarabrams:jarjar-abrams-core) // perhaps the class can be copied here? Agg(build.Deps.scalaReflect(scalaVersion())) - } + } ++ + Agg(build.Deps.zinc) } def testIvyDeps = super.testIvyDeps() ++ Agg(build.Deps.TestDeps.scalaCheck) def testTransitiveDeps = diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala index 551b9021a595..2f20f3774c1b 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -29,6 +29,10 @@ import mill.scalalib.bsp.BspModule import mill.scalalib.publish.Artifact import mill.util.Jvm import os.Path +import mill.testrunner.TestResult +import mill.scalalib.api.TransitiveSourceStampResults +import scala.collection.immutable.TreeMap +import scala.util.Try /** * Core configuration required to compile a single Java compilation target @@ -103,6 +107,179 @@ trait JavaModule case _: ClassNotFoundException => // if we can't find the classes, we certainly are not in a ScalaJSModule } } + + def testQuick(args: String*): Command[(String, Seq[TestResult])] = + Task.Command(persistent = true) { + val quicktestFailedClassesLog = Task.dest / "quickTestFailedClasses.json" + val invalidatedClassesLog = Task.dest / "invalidatedClasses.json" + 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 transitiveStampsFile = Task.dest / "transitiveStamps.json" + val previousStampsOpt = if (os.exists(transitiveStampsFile)) { + val previousStamps = upickle.default.read[TransitiveSourceStampResults]( + os.read.stream(transitiveStampsFile) + ).currentStamps + os.remove(transitiveStampsFile) + Some(previousStamps) + } else { + None + } + + def getAnalysisStore(compileResult: CompilationResult) + : Option[xsbti.compile.CompileAnalysis] = { + val analysisStore = sbt.internal.inc.consistent.ConsistentFileAnalysisStore.binary( + file = compileResult.analysisFile.toIO, + mappers = xsbti.compile.analysis.ReadWriteMappers.getEmptyMappers(), + reproducible = true, + parallelism = math.min(Runtime.getRuntime.availableProcessors(), 8) + ) + val analysisOptional = analysisStore.get() + if (analysisOptional.isPresent) Some(analysisOptional.get.getAnalysis) else None + } + + val combinedAnalysis = (compile() +: upstreamCompileOutput()) + .flatMap(getAnalysisStore) + .flatMap { + case analysis: sbt.internal.inc.Analysis => Some(analysis) + case _ => None + } + .foldLeft(sbt.internal.inc.Analysis.empty)(_ ++ _) + + val result = TransitiveSourceStampResults( + currentStamps = TreeMap.from( + combinedAnalysis.stamps.sources.view.map { (source, stamp) => + source.id() -> stamp.writeStamp() + } + ), + previousStamps = previousStampsOpt + ) + + def getInvalidatedClasspaths( + initialInvalidatedClassNames: Set[String], + relations: sbt.internal.inc.Relations + ): Set[os.Path] = { + val seen = collection.mutable.Set.empty[String] + val seenList = collection.mutable.Buffer.empty[String] + val queued = collection.mutable.Queue.from(initialInvalidatedClassNames) + + while (queued.nonEmpty) { + val current = queued.dequeue() + seenList.append(current) + seen.add(current) + + for (next <- relations.usesInternalClass(current)) { + if (!seen.contains(next)) { + seen.add(next) + queued.enqueue(next) + } + } + + for (next <- relations.usesExternal(current)) { + if (!seen.contains(next)) { + seen.add(next) + queued.enqueue(next) + } + } + } + + seenList + .iterator + .flatMap { invalidatedClassName => + relations.definesClass(invalidatedClassName) + } + .flatMap { source => + relations.products(source) + } + .map { product => + os.Path(product.id) + } + .toSet + } + + val relations = combinedAnalysis.relations + + val invalidatedAbsoluteClasspaths = getInvalidatedClasspaths( + result.changedSources.flatMap { source => + relations.classNames(xsbti.VirtualFileRef.of(source)) + }, + combinedAnalysis.relations + ) + + // We only care about testing class, so we can: + // - filter out all class path that start with `testClasspath()` + // - strip the prefix and safely turn them into module class path + + val testClasspaths = testClasspath() + val invalidatedClassNames = invalidatedAbsoluteClasspaths.flatMap { absoluteClasspath => + testClasspaths.collectFirst { + case path if absoluteClasspath.startsWith(path.path) => + absoluteClasspath.relativeTo( + path.path + ).segments.map(_.stripSuffix(".class")).mkString(".") + } + } + val testingClasses = invalidatedClassNames ++ failedTestClasses + val testClasses = + testForkGrouping().map(_.filter(testingClasses.contains)).filter(_.nonEmpty) + + // 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(), + testClasspaths, + args.toSeq, + testClasses, + 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 + testClasses.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(transitiveStampsFile, upickle.default.write(result)) + os.write.over(quicktestFailedClassesLog, upickle.default.write(badTestClasses)) + os.write.over(invalidatedClassesLog, 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" diff --git a/scalalib/src/mill/scalalib/TestModule.scala b/scalalib/src/mill/scalalib/TestModule.scala index f32ea6a1e93b..0d1cbef5b8bc 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 c7aee148aecd..0f8d50a2bdea 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/testkit/src/mill/testkit/UnitTester.scala b/testkit/src/mill/testkit/UnitTester.scala index b4a75b818e0c..2750cc617547 100644 --- a/testkit/src/mill/testkit/UnitTester.scala +++ b/testkit/src/mill/testkit/UnitTester.scala @@ -52,7 +52,7 @@ class UnitTester( inStream: InputStream, debugEnabled: Boolean, env: Map[String, String], - resetSourcePath: Boolean + resetSourcePath: Boolean = true )(implicit fullName: sourcecode.FullName) extends AutoCloseable { val outPath: os.Path = module.moduleDir / "out"