Skip to content

Commit 690dacc

Browse files
committed
zinc based test quick command
1 parent 59b350f commit 690dacc

File tree

7 files changed

+230
-21
lines changed

7 files changed

+230
-21
lines changed

core/define/src/mill/define/Task.scala

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,12 @@ object Task extends TaskBase {
124124
inline def Command[T](inline t: Result[T])(implicit
125125
inline w: W[T],
126126
inline ctx: mill.define.ModuleCtx
127-
): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }) }
127+
): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }, '{ false }) }
128+
129+
inline def Command[T](persistent: Boolean)(inline t: Result[T])(implicit
130+
inline w: W[T],
131+
inline ctx: mill.define.ModuleCtx
132+
): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, exclusive = '{ false }, '{ persistent }) }
128133

129134
/**
130135
* @param exclusive Exclusive commands run serially at the end of an evaluation,
@@ -141,7 +146,7 @@ object Task extends TaskBase {
141146
class CommandFactory private[mill] (val exclusive: Boolean) {
142147
inline def apply[T](inline t: Result[T])(implicit
143148
inline w: W[T],
144-
inline ctx: mill.define.ModuleCtx
149+
inline ctx: mill.define.Ctx
145150
): Command[T] = ${ TaskMacros.commandImpl[T]('t)('w, 'ctx, '{ this.exclusive }) }
146151
}
147152

@@ -394,7 +399,8 @@ class Command[+T](
394399
val ctx0: mill.define.ModuleCtx,
395400
val writer: W[?],
396401
val isPrivate: Option[Boolean],
397-
val exclusive: Boolean
402+
val exclusive: Boolean,
403+
override val persistent: Boolean
398404
) extends NamedTask[T] {
399405

400406
override def asCommand: Some[Command[T]] = Some(this)
@@ -540,13 +546,13 @@ private object TaskMacros {
540546
Quotes
541547
)(t: Expr[Result[T]])(
542548
w: Expr[W[T]],
543-
ctx: Expr[mill.define.ModuleCtx],
549+
ctx: Expr[mill.define.Ctx],
544550
exclusive: Expr[Boolean]
545551
): Expr[Command[T]] = {
546552
appImpl[Command, T](
547553
(in, ev) =>
548554
'{
549-
new Command[T]($in, $ev, $ctx, $w, ${ taskIsPrivate() }, exclusive = $exclusive)
555+
new Command[T]($in, $ev, $ctx, $w, ${ taskIsPrivate() }, exclusive = $exclusive, persistent = $persistent)
550556
},
551557
t
552558
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package mill.scalalib.api
2+
3+
final case class TransitiveSourceStampResults(
4+
currentStamps: Map[String, String],
5+
previousStamps: Option[Map[String, String]] = None
6+
) {
7+
lazy val changedSources: Set[String] = {
8+
previousStamps match {
9+
case Some(prevStamps) =>
10+
currentStamps.view
11+
.flatMap { (source, stamp) =>
12+
prevStamps.get(source) match {
13+
case None => Some(source) // new source
14+
case Some(prevStamp) => Option.when(stamp != prevStamp)(source) // changed source
15+
}
16+
}
17+
.toSet
18+
case None => currentStamps.keySet
19+
}
20+
}
21+
}
22+
23+
object TransitiveSourceStampResults {
24+
implicit val jsonFormatter: upickle.default.ReadWriter[TransitiveSourceStampResults] =
25+
upickle.default.macroRW
26+
}

scalalib/package.mill

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ object `package` extends RootModule with build.MillStableScalaModule {
2525
// (also transitively included by com.eed3si9n.jarjarabrams:jarjar-abrams-core)
2626
// perhaps the class can be copied here?
2727
Agg(build.Deps.scalaReflect(scalaVersion()))
28-
}
28+
} ++
29+
Agg(build.Deps.zinc)
2930
}
3031
def testIvyDeps = super.testIvyDeps() ++ Agg(build.Deps.TestDeps.scalaCheck)
3132
def testTransitiveDeps =

scalalib/src/mill/scalalib/JavaModule.scala

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import mill.scalalib.bsp.BspModule
2929
import mill.scalalib.publish.Artifact
3030
import mill.util.Jvm
3131
import os.Path
32+
import mill.testrunner.TestResult
33+
import mill.scalalib.api.TransitiveSourceStampResults
34+
import scala.collection.immutable.TreeMap
35+
import scala.util.Try
3236

3337
/**
3438
* Core configuration required to compile a single Java compilation target
@@ -103,6 +107,173 @@ trait JavaModule
103107
case _: ClassNotFoundException => // if we can't find the classes, we certainly are not in a ScalaJSModule
104108
}
105109
}
110+
111+
def testQuick(args: String*): Command[(String, Seq[TestResult])] = Task.Command(persistent = true) {
112+
val quicktestFailedClassesLog = Task.dest / "quickTestFailedClasses.json"
113+
val invalidatedClassesLog = Task.dest / "invalidatedClasses.json"
114+
val failedTestClasses =
115+
if (!os.exists(quicktestFailedClassesLog)) {
116+
Set.empty[String]
117+
} else {
118+
Try {
119+
upickle.default.read[Seq[String]](os.read.stream(quicktestFailedClassesLog))
120+
}.getOrElse(Seq.empty[String]).toSet
121+
}
122+
123+
val transitiveStampsFile = Task.dest / "transitiveStamps.json"
124+
val previousStampsOpt = if (os.exists(transitiveStampsFile)) {
125+
val previousStamps = upickle.default.read[TransitiveSourceStampResults](
126+
os.read.stream(transitiveStampsFile)
127+
).currentStamps
128+
os.remove(transitiveStampsFile)
129+
Some(previousStamps)
130+
} else {
131+
None
132+
}
133+
134+
def getAnalysisStore(compileResult: CompilationResult): Option[xsbti.compile.CompileAnalysis] = {
135+
val analysisStore = sbt.internal.inc.consistent.ConsistentFileAnalysisStore.binary(
136+
file = compileResult.analysisFile.toIO,
137+
mappers = xsbti.compile.analysis.ReadWriteMappers.getEmptyMappers(),
138+
reproducible = true,
139+
parallelism = math.min(Runtime.getRuntime.availableProcessors(), 8)
140+
)
141+
val analysisOptional = analysisStore.get()
142+
if (analysisOptional.isPresent) Some(analysisOptional.get.getAnalysis) else None
143+
}
144+
145+
val combinedAnalysis = (compile() +: upstreamCompileOutput())
146+
.flatMap(getAnalysisStore)
147+
.flatMap {
148+
case analysis: sbt.internal.inc.Analysis => Some(analysis)
149+
case _ => None
150+
}
151+
.foldLeft(sbt.internal.inc.Analysis.empty)(_ ++ _)
152+
153+
val result = TransitiveSourceStampResults(
154+
currentStamps = TreeMap.from(
155+
combinedAnalysis.stamps.sources.view.map { (source, stamp) =>
156+
source.id() -> stamp.writeStamp()
157+
}
158+
),
159+
previousStamps = previousStampsOpt
160+
)
161+
162+
def getInvalidatedClasspaths(
163+
initialInvalidatedClassNames: Set[String],
164+
relations: sbt.internal.inc.Relations
165+
): Set[os.Path] = {
166+
val seen = collection.mutable.Set.empty[String]
167+
val seenList = collection.mutable.Buffer.empty[String]
168+
val queued = collection.mutable.Queue.from(initialInvalidatedClassNames)
169+
170+
while (queued.nonEmpty) {
171+
val current = queued.dequeue()
172+
seenList.append(current)
173+
seen.add(current)
174+
175+
for (next <- relations.usesInternalClass(current)) {
176+
if (!seen.contains(next)) {
177+
seen.add(next)
178+
queued.enqueue(next)
179+
}
180+
}
181+
182+
for (next <- relations.usesExternal(current)) {
183+
if (!seen.contains(next)) {
184+
seen.add(next)
185+
queued.enqueue(next)
186+
}
187+
}
188+
}
189+
190+
seenList
191+
.iterator
192+
.flatMap { invalidatedClassName =>
193+
relations.definesClass(invalidatedClassName)
194+
}
195+
.flatMap { source =>
196+
relations.products(source)
197+
}
198+
.map { product =>
199+
os.Path(product.id)
200+
}
201+
.toSet
202+
}
203+
204+
val relations = combinedAnalysis.relations
205+
206+
val invalidatedAbsoluteClasspaths = getInvalidatedClasspaths(
207+
result.changedSources.flatMap { source =>
208+
relations.classNames(xsbti.VirtualFileRef.of(source))
209+
},
210+
combinedAnalysis.relations
211+
)
212+
213+
// We only care about testing class, so we can:
214+
// - filter out all class path that start with `testClasspath()`
215+
// - strip the prefix and safely turn them into module class path
216+
217+
val testClasspaths = testClasspath()
218+
val invalidatedClassNames = invalidatedAbsoluteClasspaths.flatMap { absoluteClasspath =>
219+
testClasspaths.collectFirst {
220+
case path if absoluteClasspath.startsWith(path.path) =>
221+
absoluteClasspath.relativeTo(path.path).segments.map(_.stripSuffix(".class")).mkString(".")
222+
}
223+
}
224+
val testingClasses = invalidatedClassNames ++ failedTestClasses
225+
val testClasses = testForkGrouping().map(_.filter(testingClasses.contains)).filter(_.nonEmpty)
226+
227+
// Clean up the directory for test runners
228+
os.walk(Task.dest).foreach { subPath => os.remove.all(subPath) }
229+
230+
val quickTestReportXml = testReportXml()
231+
232+
val testModuleUtil = new TestModuleUtil(
233+
testUseArgsFile(),
234+
forkArgs(),
235+
Seq.empty,
236+
zincWorker().scalalibClasspath(),
237+
resources(),
238+
testFramework(),
239+
runClasspath(),
240+
testClasspaths,
241+
args.toSeq,
242+
testClasses,
243+
zincWorker().testrunnerEntrypointClasspath(),
244+
forkEnv(),
245+
testSandboxWorkingDir(),
246+
forkWorkingDir(),
247+
quickTestReportXml,
248+
zincWorker().javaHome().map(_.path),
249+
testParallelism()
250+
)
251+
252+
val results = testModuleUtil.runTests()
253+
254+
val badTestClasses = (results match {
255+
case Result.Failure(_) =>
256+
// Consider all quick testing classes as failed
257+
testClasses.flatten
258+
case Result.Success((_, results)) =>
259+
// Get all test classes that failed
260+
results
261+
.filter(testResult => Set("Error", "Failure").contains(testResult.status))
262+
.map(_.fullyQualifiedName)
263+
}).distinct
264+
265+
os.write.over(transitiveStampsFile, upickle.default.write(result))
266+
os.write.over(quicktestFailedClassesLog, upickle.default.write(badTestClasses))
267+
os.write.over(invalidatedClassesLog, upickle.default.write(invalidatedClassNames))
268+
results match {
269+
case Result.Failure(errMsg) => Result.Failure(errMsg)
270+
case Result.Success((doneMsg, results)) =>
271+
try TestModule.handleResults(doneMsg, results, Task.ctx(), quickTestReportXml)
272+
catch {
273+
case e: Throwable => Result.Failure("Test reporting failed: " + e)
274+
}
275+
}
276+
}
106277
}
107278

108279
def defaultCommandName(): String = "run"

scalalib/src/mill/scalalib/TestModule.scala

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,12 @@ trait TestModule
205205
globSelectors: Task[Seq[String]]
206206
): Task[(String, Seq[TestResult])] =
207207
Task.Anon {
208+
val testGlobSelectors = globSelectors()
209+
val reportXml = testReportXml()
208210
val testModuleUtil = new TestModuleUtil(
209211
testUseArgsFile(),
210212
forkArgs(),
211-
globSelectors(),
213+
testGlobSelectors,
212214
jvmWorker().scalalibClasspath(),
213215
resources(),
214216
testFramework(),
@@ -220,12 +222,25 @@ trait TestModule
220222
forkEnv(),
221223
testSandboxWorkingDir(),
222224
forkWorkingDir(),
223-
testReportXml(),
225+
reportXml,
224226
jvmWorker().javaHome().map(_.path),
225227
testParallelism(),
226228
testLogLevel()
227229
)
228-
testModuleUtil.runTests()
230+
val result = testModuleUtil.runTests()
231+
232+
result match {
233+
case Result.Failure(errMsg) => Result.Failure(errMsg)
234+
case Result.Success((doneMsg, results)) =>
235+
if (results.isEmpty && testGlobSelectors.nonEmpty) throw new Result.Exception(
236+
s"Test selector does not match any test: ${testGlobSelectors.mkString(" ")}" +
237+
"\nRun discoveredTestClasses to see available tests"
238+
)
239+
try TestModule.handleResults(doneMsg, results, Task.ctx(), reportXml)
240+
catch {
241+
case e: Throwable => Result.Failure("Test reporting failed: " + e)
242+
}
243+
}
229244
}
230245

231246
/**

scalalib/src/mill/scalalib/TestModuleUtil.scala

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,21 +102,11 @@ private final class TestModuleUtil(
102102
}
103103
if (selectors.nonEmpty && filteredClassLists.isEmpty) throw doesNotMatchError
104104

105-
val result = if (testParallelism) {
105+
if (testParallelism) {
106106
runTestQueueScheduler(filteredClassLists)
107107
} else {
108108
runTestDefault(filteredClassLists)
109109
}
110-
111-
result match {
112-
case Result.Failure(errMsg) => Result.Failure(errMsg)
113-
case Result.Success((doneMsg, results)) =>
114-
if (results.isEmpty && selectors.nonEmpty) throw doesNotMatchError
115-
try TestModuleUtil.handleResults(doneMsg, results, Task.ctx(), testReportXml)
116-
catch {
117-
case e: Throwable => Result.Failure("Test reporting failed: " + e)
118-
}
119-
}
120110
}
121111

122112
private def callTestRunnerSubprocess(

testkit/src/mill/testkit/UnitTester.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class UnitTester(
5252
inStream: InputStream,
5353
debugEnabled: Boolean,
5454
env: Map[String, String],
55-
resetSourcePath: Boolean
55+
resetSourcePath: Boolean = true
5656
)(implicit fullName: sourcecode.FullName) extends AutoCloseable {
5757
val outPath: os.Path = module.moduleDir / "out"
5858

0 commit comments

Comments
 (0)