Skip to content

Commit

Permalink
Part of Android Studio Integration: Standardize test output (#4334)
Browse files Browse the repository at this point in the history
## Motivation

As part of our testing to integrate Mill with Android Studio, we are
experimenting with BSP integration. The stubbed test method is not
compatible with the expected BSP interfaces.

## Provided in this PR

The raw output provided in ADB instrumented testing is translated to
TestResults as expected from the test module interface.

## Not provided in this PR ( will be done in subsequent PRs ) 

- Handling parallelization 
- Selectors ( e.g for use in test only )
- Test failure scenarios ( with examples )
- Stack traces and test output
  • Loading branch information
irodotos7 authored Jan 15, 2025
1 parent 0063848 commit a44bafc
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 15 deletions.
19 changes: 14 additions & 5 deletions example/android/javalib/1-hello-world/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,20 @@ object app extends AndroidAppModule {
> ./mill show app.adbDevices
...emulator-5554...device...

> ./mill show app.it | grep '"OK (1 test)"'
..."OK (1 test)",

> cat out/app/it/test.json | grep '"OK (1 test)"'
..."OK (1 test)"...
> ./mill show app.it
...
[
"",
[
{
"fullyQualifiedName": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"selector": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"duration": ...,
"status": "Success"
}
]
]
...

> ./mill show app.stopAndroidEmulator

Expand Down
19 changes: 14 additions & 5 deletions example/android/kotlinlib/1-hello-kotlin/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,20 @@ object app extends AndroidAppKotlinModule {
> ./mill show app.adbDevices
...emulator-5556...device...

> ./mill show app.it | grep '"OK (1 test)"'
..."OK (1 test)",

> cat out/app/it/test.json | grep '"OK (1 test)"'
..."OK (1 test)"...
> ./mill show app.it
...
[
"",
[
{
"fullyQualifiedName": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"selector": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"duration": ...,
"status": "Success"
}
]
]
...

> ./mill show app.stopAndroidEmulator

Expand Down
17 changes: 12 additions & 5 deletions scalalib/src/mill/javalib/android/AndroidAppModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import mill._
import mill.scalalib._
import mill.api.{Logger, PathRef}
import mill.define.ModuleRef
import mill.testrunner.TestResult
import mill.util.Jvm
import os.RelPath
import upickle.default._
Expand Down Expand Up @@ -1063,10 +1064,13 @@ trait AndroidAppModule extends JavaModule {
emulator
}

def test: T[Vector[String]] = Task {
override def testTask(
args: Task[Seq[String]],
globSelectors: Task[Seq[String]]
): Task[(String, Seq[TestResult])] = Task {
val device = androidInstall()

val instrumentOutput = os.call(
val instrumentOutput = os.proc(
(
androidSdkModule().adbPath().path,
"-s",
Expand All @@ -1075,11 +1079,14 @@ trait AndroidAppModule extends JavaModule {
"am",
"instrument",
"-w",
"-m",
"-r",
s"$instrumentationPackage/${testFramework()}"
)
)
instrumentOutput.out.lines()
).spawn()

val outputReader = instrumentOutput.stdout.buffered

InstrumentationOutput.parseTestOutputStream(outputReader)(T.log)
}

/** Builds the apk including the integration tests (e.g. from androidTest) */
Expand Down
119 changes: 119 additions & 0 deletions scalalib/src/mill/javalib/android/InstrumentationOutput.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package mill.javalib.android

import mill.api.Logger
import mill.testrunner.TestResult

import java.io.BufferedReader
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration
import scala.jdk.CollectionConverters.IteratorHasAsScala

private[android] sealed trait InstrumentationOutput

private[android] object InstrumentationOutput {

case class TestClassName(className: String) extends InstrumentationOutput

case class TestMethodName(methodName: String) extends InstrumentationOutput

case object StatusStarted extends InstrumentationOutput

case object StatusOk extends InstrumentationOutput

case object StatusFailure extends InstrumentationOutput

case object StatusError extends InstrumentationOutput

case class Ignored(line: String) extends InstrumentationOutput

private case class TimeResultState(
started: Long,
currentTestResult: TestResult,
testResults: Seq[TestResult]
)
private val testResultStarted = TestResult("", "", 0L, "")

/* Inspiration from:
https://android.googlesource.com/platform/development/+/52d4c30ca52320ec92d1d1ddc8db3f07f69c4f98/tools/ddms/libs/ddmlib/src/com/android/ddmlib/testrunner/InstrumentationResultParser.java
*/

def parseTestOutputStream(outputReader: BufferedReader)(logger: Logger)
: (String, Seq[TestResult]) = {
val state = outputReader.lines().iterator().asScala.foldLeft(TimeResultState(
0L,
testResultStarted,
Seq.empty
)) {
case (state, nextLine) =>
logger.debug(nextLine)
parseLine(nextLine) match {
case InstrumentationOutput.TestClassName(className) =>
logger.debug(s"TestClassName=$className")
TimeResultState(
state.started,
state.currentTestResult.copy(fullyQualifiedName = className),
state.testResults
)
case InstrumentationOutput.TestMethodName(methodName) =>
logger.debug(s"TestMethodName=$methodName")
val fullyQualifiedNAme = s"${state.currentTestResult.fullyQualifiedName}.${methodName}"
state.copy(currentTestResult =
state.currentTestResult.copy(
fullyQualifiedName = fullyQualifiedNAme,
selector = fullyQualifiedNAme
)
)
case InstrumentationOutput.StatusStarted =>
logger.debug(s"StatusStarted")
state.copy(started = System.currentTimeMillis())
case InstrumentationOutput.StatusOk =>
logger.debug(s"StatusOk")
val ended = System.currentTimeMillis()
val duration = FiniteDuration.apply(ended - state.started, TimeUnit.MILLISECONDS)
val testResult =
state.currentTestResult.copy(duration = duration.toSeconds, status = "Success")
TimeResultState(state.started, testResultStarted, state.testResults :+ testResult)
case InstrumentationOutput.StatusFailure =>
logger.debug(s"StatusFailure")
val ended = System.currentTimeMillis()
val duration = FiniteDuration.apply(ended - state.started, TimeUnit.MILLISECONDS)
val testResult =
state.currentTestResult.copy(duration = duration.toSeconds, status = "Failure")
TimeResultState(state.started, testResultStarted, state.testResults :+ testResult)
case InstrumentationOutput.StatusError =>
logger.debug(s"StatusError")
val ended = System.currentTimeMillis()
val duration = FiniteDuration.apply(ended - state.started, TimeUnit.MILLISECONDS)
val testResult =
state.currentTestResult.copy(duration = duration.toSeconds, status = "Error")
TimeResultState(state.started, testResultStarted, state.testResults :+ testResult)
case InstrumentationOutput.Ignored(line) =>
// todo handle stream and stack
logger.debug(s"Message ${line}, ignored")
state
}
}
("", state.testResults)
}

def parseLine(line: String): InstrumentationOutput = {
if (line.contains("class=")) {
val parts = line.split("class=")
TestClassName(parts(1))
} else if (line.contains("test=")) {
val parts = line.split("test=")
TestMethodName(parts(1))
} else if (line.contains("INSTRUMENTATION_STATUS_CODE:")) {
val parts = line.split(" ")
parts(1).trim() match {
case "1" => StatusStarted
case "0" => StatusOk
case "-1" => StatusError
case "-2" => StatusFailure
case _ => Ignored(line)
}
} else {
Ignored(line)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package mill.javalib.android

import mill.scalalib.AssemblyTestUtils
import utest._
import mill.javalib.android.InstrumentationOutput._
import mill.util.DummyLogger

import java.io.{BufferedReader, StringReader}

object InstrumentalOutputReportTest extends TestSuite with AssemblyTestUtils {

def tests: Tests = Tests {
test("parseLine should parse class names correctly") {
val line = "INSTRUMENTATION_STATUS: class=com.example.MyClass"
val result = parseLine(line)
assert(result == TestClassName("com.example.MyClass"))
}

test("parseLine should parse method names correctly") {
val line = "INSTRUMENTATION_STATUS: test=myTestMethod"
val result = parseLine(line)
assert(result == TestMethodName("myTestMethod"))
}

test("parseLine should parse status codes correctly") {
assert(parseLine("INSTRUMENTATION_STATUS_CODE: 1") == StatusStarted)
assert(parseLine("INSTRUMENTATION_STATUS_CODE: 0") == StatusOk)
assert(parseLine("INSTRUMENTATION_STATUS_CODE: -1") == StatusError)
assert(parseLine("INSTRUMENTATION_STATUS_CODE: -2") == StatusFailure)
}

test("parseLine should handle unrecognized lines gracefully") {
val line = "unrelated log line"
val result = parseLine(line)
assert(result == Ignored("unrelated log line"))
}

test("parse test stream into test result") {
val testOutput =
s"""
INSTRUMENTATION_STATUS: class=com.helloworld.app.ExampleInstrumentedTest
|INSTRUMENTATION_STATUS: current=1
|INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|INSTRUMENTATION_STATUS: numtests=1
|INSTRUMENTATION_STATUS: stream=
|com.helloworld.app.ExampleInstrumentedTest:
|INSTRUMENTATION_STATUS: test=useAppContext
|INSTRUMENTATION_STATUS_CODE: 1
|INSTRUMENTATION_STATUS: class=com.helloworld.app.ExampleInstrumentedTest
|INSTRUMENTATION_STATUS: current=1
|INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|INSTRUMENTATION_STATUS: numtests=1
|INSTRUMENTATION_STATUS: stream=.
|INSTRUMENTATION_STATUS: test=useAppContext
|INSTRUMENTATION_STATUS_CODE: 0
|INSTRUMENTATION_RESULT: stream=
|
|Time: 0.005
|
|OK (1 test)
|
|
|INSTRUMENTATION_CODE: -1
""".stripMargin

val reader = new BufferedReader(new StringReader(testOutput))

val (_, testResults) = parseTestOutputStream(reader)(DummyLogger)

assert(testResults.size == 1)
assert(
testResults.head.fullyQualifiedName == "com.helloworld.app.ExampleInstrumentedTest.useAppContext"
)
assert(testResults.head.duration == 0L)
assert(testResults.head.status == "Success")
assert(
testResults.head.selector == "com.helloworld.app.ExampleInstrumentedTest.useAppContext"
)

}
}
}

0 comments on commit a44bafc

Please sign in to comment.