Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions integration/feature/testquick-javamodule/resources/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package build
import mill._, javalib._

object foo extends JavaModule {
def javacOptions = Seq("-source", "11", "-target", "11")

object test extends JavaTests with TestModule.Junit4
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package foo;

public class Calculator {
public int add(int a, int b) {
return a + b;
}

public int subtract(int a, int b) {
return a - b;
}

public int multiply(int a, int b) {
return a * b;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package foo;

public class StringUtils {
public static String reverse(String s) {
return new StringBuilder(s).reverse().toString();
}

public static String toUpperCase(String s) {
return s.toUpperCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package foo;

import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {
@Test
public void testAdd() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}

@Test
public void testSubtract() {
Calculator calc = new Calculator();
assertEquals(1, calc.subtract(3, 2));
}

@Test
public void testMultiply() {
Calculator calc = new Calculator();
assertEquals(6, calc.multiply(2, 3));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package foo;

import org.junit.Test;
import static org.junit.Assert.*;

public class StringUtilsTest {
@Test
public void testReverse() {
assertEquals("cba", StringUtils.reverse("abc"));
}

@Test
public void testToUpperCase() {
assertEquals("HELLO", StringUtils.toUpperCase("hello"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package mill.integration

import mill.testkit.UtestIntegrationTestSuite

import utest._

object TestQuickJavaModuleTests extends UtestIntegrationTestSuite {
val tests: Tests = Tests {
def filterLines(out: String) = {
out.linesIterator.filter(!_.contains("[info]")).toSet
}

test("testQuick-runs-all-on-first-run") - integrationTest { tester =>
import tester._

// First run should execute all tests
val initial = eval("foo.test.testQuick")
assert(initial.isSuccess)

// Should see both test classes run
val initialOut = initial.out
assert(
initialOut.contains("CalculatorTest") ||
initialOut.contains("running") ||
initialOut.contains("passed")
)
}

test("testQuick-caches-unchanged") - integrationTest { tester =>
import tester._

// First run
val initial = eval("foo.test.testQuick")
assert(initial.isSuccess)

// Second run with no changes - should run fewer tests or be cached
val cached = eval("foo.test.testQuick")
assert(cached.isSuccess)
}

test("testQuick-detects-changes") - integrationTest { tester =>
import tester._

// First run to establish baseline
val initial = eval("foo.test.testQuick")
assert(initial.isSuccess)

// Modify Calculator.java - only CalculatorTest should run
modifyFile(
workspacePath / "foo/src/Calculator.java",
_.replace("return a + b", "return a + b + 0")
)

val afterChange = eval("foo.test.testQuick")
assert(afterChange.isSuccess)

// The test should have detected the change and re-run affected tests
assert(
afterChange.out.contains("CalculatorTest") ||
afterChange.out.contains("running") ||
afterChange.out.contains("1 passed")
)
}

test("testQuick-reruns-failed") - integrationTest { tester =>
import tester._

// First run
val initial = eval("foo.test.testQuick")
assert(initial.isSuccess)

// Introduce a failing test by breaking the implementation
modifyFile(
workspacePath / "foo/src/Calculator.java",
_.replace("return a + b", "return a - b") // Break add() to make test fail
)

// This run should fail
val failing = eval("foo.test.testQuick", check = false)

// Fix the implementation
modifyFile(
workspacePath / "foo/src/Calculator.java",
_.replace("return a - b", "return a + b") // Fix it back
)

// Next run should re-run the previously failed test
val fixed = eval("foo.test.testQuick")
assert(fixed.isSuccess)
}
}
}
95 changes: 95 additions & 0 deletions libs/javalib/api/src/mill/javalib/codesig/CodeSigWorkerApi.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package mill.javalib.codesig

/**
* API for computing bytecode-level method code signatures using Mill's codesig module.
* Used by testQuick for fine-grained change detection at the method level.
*/
trait CodeSigWorkerApi extends AutoCloseable {

/**
* Computes transitive call graph hashes for compiled class files.
* These hashes capture both the method's own bytecode and all methods it transitively calls.
*
* @param classFiles Paths to compiled .class files
* @param upstreamClasspath Paths to upstream dependency JARs/directories
* @param logDir Optional directory for debug logging
* @param prevHashesOpt Previous hashes for incremental computation
* @return Map from method signature to its transitive call graph hash
*/
def computeCodeSignatures(
classFiles: Seq[os.Path],
upstreamClasspath: Seq[os.Path],
logDir: Option[os.Path],
prevHashesOpt: Option[Map[String, Int]]
): Map[String, Int]

/**
* Computes direct method code hashes (without transitive dependencies).
*
* @param classFiles Paths to compiled .class files
* @param upstreamClasspath Paths to upstream dependency JARs/directories
* @return Map from method signature to its direct code hash
*/
def computeMethodHashes(
classFiles: Seq[os.Path],
upstreamClasspath: Seq[os.Path]
): Map[String, Int]

override def close(): Unit = {
// noop by default
}
}

object CodeSigWorkerApi {

/**
* Extracts class name from a codesig method signature.
* Format: "def package.ClassName#methodName(args)returnType"
* or "package.ClassName.methodName(args)returnType" for static methods
*/
def extractClassName(methodSig: String): Option[String] = {
val sig = methodSig.stripPrefix("def ").stripPrefix("call ").stripPrefix("external ")
val hashIdx = sig.indexOf('#')
val parenIdx = sig.indexOf('(')

if (hashIdx > 0) {
// Instance method: "package.ClassName#method"
Some(sig.substring(0, hashIdx))
} else if (parenIdx > 0) {
// Static method: "package.ClassName.method(args)"
val lastDot = sig.lastIndexOf('.', parenIdx)
if (lastDot > 0) Some(sig.substring(0, lastDot))
else None
} else {
None
}
}

/**
* Groups method signatures by class name.
*/
def groupByClass(signatures: Map[String, Int]): Map[String, Map[String, Int]] = {
signatures.groupBy { case (sig, _) =>
extractClassName(sig).getOrElse("unknown")
}
}

/**
* Computes a single hash for a class by combining all its method hashes.
*/
def computeClassHash(methodHashes: Map[String, Int]): Int = {
// Use XOR to make hash order-independent
methodHashes.toSeq.sortBy(_._1).foldLeft(0) { case (acc, (sig, hash)) =>
acc ^ (sig.hashCode * 31 + hash)
}
}

/**
* Converts method-level signatures to class-level signatures.
*/
def toClassSignatures(methodSignatures: Map[String, Int]): Map[String, Int] = {
groupByClass(methodSignatures).map { case (className, methods) =>
className -> computeClassHash(methods)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package mill.javalib.codesig

import mill.codesig.{CodeSig, Logger}
import mill.codesig.JvmModel.{Desc, MethodSig}

/**
* Worker implementation that computes method code hash signatures using Mill's codesig module.
* This provides fine-grained bytecode-level change detection for testQuick.
*/
class CodeSigWorker extends CodeSigWorkerApi {

/**
* Computes transitive call graph hashes for compiled class files.
*
* @param classFiles Paths to compiled .class files
* @param upstreamClasspath Paths to upstream dependency JARs/directories
* @param logDir Optional directory for debug logging
* @param prevHashesOpt Previous hashes for incremental computation
* @return Map from method signature to its transitive call graph hash
*/
override def computeCodeSignatures(
classFiles: Seq[os.Path],
upstreamClasspath: Seq[os.Path],
logDir: Option[os.Path],
prevHashesOpt: Option[Map[String, Int]]
): Map[String, Int] = {
if (classFiles.isEmpty) return Map.empty

val analysis = CodeSig.compute(
classFiles = classFiles,
upstreamClasspath = upstreamClasspath,
ignoreCall = defaultIgnoreCall,
logger = new Logger(
logDir.getOrElse(os.temp.dir()),
logDir
),
prevTransitiveCallGraphHashesOpt = () => prevHashesOpt
)

analysis.transitiveCallGraphHashes.toMap
}

/**
* Computes direct method code hashes (without transitive dependencies).
*
* @param classFiles Paths to compiled .class files
* @param upstreamClasspath Paths to upstream dependency JARs/directories
* @return Map from method signature to its direct code hash
*/
override def computeMethodHashes(
classFiles: Seq[os.Path],
upstreamClasspath: Seq[os.Path]
): Map[String, Int] = {
if (classFiles.isEmpty) return Map.empty

val analysis = CodeSig.compute(
classFiles = classFiles,
upstreamClasspath = upstreamClasspath,
ignoreCall = defaultIgnoreCall,
logger = new Logger(os.temp.dir(), None),
prevTransitiveCallGraphHashesOpt = () => None
)

analysis.methodCodeHashes.toMap
}

/**
* Default filter for ignoring certain method calls in the call graph.
* This is simpler than the build.mill version since we're analyzing
* user code, not Mill task definitions.
*/
private def defaultIgnoreCall(callSiteOpt: Option[mill.codesig.JvmModel.MethodDef], calledSig: MethodSig): Boolean = {
// Don't ignore any calls by default for test code analysis
// User code dependencies should all be tracked
false
}
}
11 changes: 10 additions & 1 deletion libs/javalib/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ object `package` extends MillStableScalaModule {
`classgraph-worker`,
`jarjarabrams-worker`,
`spotless-worker`,
`scalameta-worker`
`scalameta-worker`,
`codesig-worker`
)
def testForkEnv = {
val locale = if (Properties.isMac) "en_US.UTF-8" else "C.utf8"
Expand Down Expand Up @@ -189,4 +190,12 @@ object `package` extends MillStableScalaModule {
def moduleDeps = Seq(build.libs.javalib)
def mvnDeps = Seq(Deps.semanticDbShared)
}

/**
* Worker module for computing bytecode-level method signatures using codesig.
* Used by testQuick for fine-grained change detection.
*/
object `codesig-worker` extends MillPublishScalaModule {
def moduleDeps = Seq(api, build.runner.codesig)
}
}
Loading