Skip to content

Commit

Permalink
feat(android): add filtered logcat to am instrument parser
Browse files Browse the repository at this point in the history
  • Loading branch information
Malinskiy committed Jul 16, 2024
1 parent 7684736 commit 63444ca
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 92 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.malinskiy.marathon.android

import com.android.sdklib.AndroidVersion
import com.malinskiy.marathon.android.logcat.LogcatProducer
import com.malinskiy.marathon.android.model.ShellCommandResult
import com.malinskiy.marathon.device.screenshot.Rotation
import com.malinskiy.marathon.config.vendor.android.VideoConfiguration
import com.malinskiy.marathon.device.Device
import com.malinskiy.marathon.device.screenshot.Screenshottable
import com.malinskiy.marathon.report.logs.LogProducer

interface AndroidDevice : Device, LogProducer, Screenshottable {
interface AndroidDevice : Device, LogProducer, LogcatProducer, Screenshottable {
val apiLevel: Int
val version: AndroidVersion
val fileManager: RemoteFileManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ import com.malinskiy.marathon.android.AndroidTestBundleIdentifier
import com.malinskiy.marathon.android.BaseAndroidDevice
import com.malinskiy.marathon.android.RemoteFileManager
import com.malinskiy.marathon.android.adam.extension.toShellResult
import com.malinskiy.marathon.android.adam.log.LogCatMessage
import com.malinskiy.marathon.android.exception.CommandRejectedException
import com.malinskiy.marathon.android.exception.InstallException
import com.malinskiy.marathon.exceptions.TransferException
import com.malinskiy.marathon.execution.listener.LineListener
import com.malinskiy.marathon.android.extension.toScreenRecorderCommand
import com.malinskiy.marathon.android.logcat.LogcatListener
import com.malinskiy.marathon.config.Configuration
import com.malinskiy.marathon.config.vendor.VendorConfiguration
import com.malinskiy.marathon.config.vendor.android.SerialStrategy
Expand Down Expand Up @@ -358,13 +360,22 @@ class AdamAndroidDevice(
}
}

private val logcatListeners = CopyOnWriteArrayList<LineListener>()
private val logListeners = CopyOnWriteArrayList<LineListener>()
private val logcatListeners = CopyOnWriteArrayList<LogcatListener>()

override fun addLineListener(listener: LineListener) {
logcatListeners.add(listener)
logListeners.add(listener)
}

override fun removeLineListener(listener: LineListener) {
logListeners.remove(listener)
}

override fun addLogcatListener(listener: LogcatListener) {
logcatListeners.add(listener)
}

override fun removeLogcatListener(listener: LogcatListener) {
logcatListeners.remove(listener)
}

Expand Down Expand Up @@ -471,8 +482,14 @@ class AdamAndroidDevice(
return client.execute(runnerRequest, serial = adbSerial)
}

suspend fun onLogcat(msg: LogCatMessage) {
//Bridge to regular string lines log
onLine("${msg.timestamp} ${msg.pid}-${msg.tid}/${msg.appName} ${msg.logLevel.priorityLetter}/${msg.tag}: ${msg.message}")
logcatListeners.forEach { it.onMessage(msg) }
}

override suspend fun onLine(line: String) {
logcatListeners.forEach { listener ->
logListeners.forEach { listener ->
listener.onLine(line)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.malinskiy.adam.request.testrunner.TestStarted
import com.malinskiy.marathon.android.AndroidAppInstaller
import com.malinskiy.marathon.android.AndroidTestBundleIdentifier
import com.malinskiy.marathon.android.adam.event.TestAnnotationParser
import com.malinskiy.marathon.android.adam.log.LogcatAccumulatingListener
import com.malinskiy.marathon.android.extension.testBundlesCompat
import com.malinskiy.marathon.android.model.AndroidTestBundle
import com.malinskiy.marathon.android.model.TestIdentifier
Expand Down Expand Up @@ -56,11 +57,12 @@ class AmInstrumentTestParser(
throw e
} catch (e: TestAnnotationProducerNotFoundException) {
logger.warn {
"""
Previous parsing attempt failed for ${e.instrumentationPackage}
file: ${e.testApplication}
due to test parser misconfiguration: test annotation producer was not found. See https://docs.marathonlabs.io/runner/android/configure#test-parser
Next parsing attempt will remove overridden test run listener.
"""Previous parsing attempt failed for ${e.instrumentationPackage}
file: ${e.testApplication}
due to test parser misconfiguration: test annotation producer was not found. See https://docs.marathonlabs.io/runner/android/configure#test-parser
Next parsing attempt will remove overridden test run listener.
Device log:
${e.logcat}
""".trimIndent()
}
blockListenerArgumentOverride = true
Expand All @@ -82,101 +84,111 @@ class AmInstrumentTestParser(
val androidAppInstaller = AndroidAppInstaller(configuration)
androidAppInstaller.prepareInstallation(device)

return testBundles.flatMap { bundle ->
val androidTestBundle =
AndroidTestBundle(bundle.application, bundle.testApplication, bundle.extraApplications, bundle.splitApks)
val instrumentationInfo = androidTestBundle.instrumentationInfo

val testParserConfiguration = vendorConfiguration.testParserConfiguration
val overrides: Map<String, String> = when {
testParserConfiguration is TestParserConfiguration.RemoteTestParserConfiguration -> {
if (blockListenerArgumentOverride) testParserConfiguration.instrumentationArgs
.filterNot { it.key == LISTENER_ARGUMENT && it.value == TEST_ANNOTATION_PRODUCER }
else testParserConfiguration.instrumentationArgs
}

else -> emptyMap()
}
val listener = LogcatAccumulatingListener(device)
listener.setup()

try {
return testBundles.flatMap { bundle ->
val androidTestBundle =
AndroidTestBundle(bundle.application, bundle.testApplication, bundle.extraApplications, bundle.splitApks)
val instrumentationInfo = androidTestBundle.instrumentationInfo

val testParserConfiguration = vendorConfiguration.testParserConfiguration
val overrides: Map<String, String> = when {
testParserConfiguration is TestParserConfiguration.RemoteTestParserConfiguration -> {
if (blockListenerArgumentOverride) testParserConfiguration.instrumentationArgs
.filterNot { it.key == LISTENER_ARGUMENT && it.value == TEST_ANNOTATION_PRODUCER }
else testParserConfiguration.instrumentationArgs
}

val runnerRequest = TestRunnerRequest(
testPackage = instrumentationInfo.instrumentationPackage,
runnerClass = instrumentationInfo.testRunnerClass,
instrumentOptions = InstrumentOptions(
log = true,
overrides = overrides,
),
supportedFeatures = device.supportedFeatures,
coroutineScope = device,
)
val channel = device.executeTestRequest(runnerRequest)
var observedAnnotations = false

val tests = mutableSetOf<Test>()
while (!channel.isClosedForReceive && isActive) {
val events: List<TestEvent>? = withTimeoutOrNull(configuration.testOutputTimeoutMillis) {
channel.receiveCatching().getOrNull() ?: emptyList()
else -> emptyMap()
}
if (events == null) {
throw TestParsingException("Unable to parse test list using ${device.serialNumber}")
} else {
throw TestAnnotationProducerNotFoundException(
instrumentationInfo.instrumentationPackage,
androidTestBundle.testApplication
)
for (event in events) {
when (event) {
is TestRunStartedEvent -> Unit
is TestStarted -> Unit
is TestFailed -> Unit
is TestAssumptionFailed -> Unit
is TestIgnored -> Unit
is TestEnded -> {
val annotations = testAnnotationParser.extractAnnotations(event)
if (annotations.isNotEmpty()) {
observedAnnotations = true

val runnerRequest = TestRunnerRequest(
testPackage = instrumentationInfo.instrumentationPackage,
runnerClass = instrumentationInfo.testRunnerClass,
instrumentOptions = InstrumentOptions(
log = true,
overrides = overrides,
),
supportedFeatures = device.supportedFeatures,
coroutineScope = device,
)
listener.start()
val channel = device.executeTestRequest(runnerRequest)
var observedAnnotations = false

val tests = mutableSetOf<Test>()
while (!channel.isClosedForReceive && isActive) {
val events: List<TestEvent>? = withTimeoutOrNull(configuration.testOutputTimeoutMillis) {
channel.receiveCatching().getOrNull() ?: emptyList()
}
if (events == null) {
throw TestParsingException("Unable to parse test list using ${device.serialNumber}")
} else {
for (event in events) {
when (event) {
is TestRunStartedEvent -> Unit
is TestStarted -> Unit
is TestFailed -> Unit
is TestAssumptionFailed -> Unit
is TestIgnored -> Unit
is TestEnded -> {
val annotations = testAnnotationParser.extractAnnotations(event)
if (annotations.isNotEmpty()) {
observedAnnotations = true
}
val test = TestIdentifier(event.id.className, event.id.testName).toTest(annotations)
tests.add(test)
testBundleIdentifier.put(test, androidTestBundle)
}
val test = TestIdentifier(event.id.className, event.id.testName).toTest(annotations)
tests.add(test)
testBundleIdentifier.put(test, androidTestBundle)
}

is TestRunFailing -> {
// Error message is stable, see https://github.com/android/android-test/blame/1ae53b93e02cc363311f6564a35edeea1b075103/runner/android_junit_runner/java/androidx/test/internal/runner/RunnerArgs.java#L624
if (event.error.contains("Could not find extra class $TEST_ANNOTATION_PRODUCER")) {
throw TestAnnotationProducerNotFoundException(
instrumentationInfo.instrumentationPackage,
androidTestBundle.testApplication
)
is TestRunFailing -> {
// Error message is stable, see https://github.com/android/android-test/blame/1ae53b93e02cc363311f6564a35edeea1b075103/runner/android_junit_runner/java/androidx/test/internal/runner/RunnerArgs.java#L624
val logcat = listener.stop()
if (event.error.contains("Could not find extra class $TEST_ANNOTATION_PRODUCER")) {
throw TestAnnotationProducerNotFoundException(
instrumentationInfo.instrumentationPackage,
androidTestBundle.testApplication,
logcat,
)
}
}
}

is TestRunFailed -> {
//Happens on Android Wear if classpath is misconfigured
if (event.error.contains("Process crashed")) {
throw TestAnnotationProducerNotFoundException(
instrumentationInfo.instrumentationPackage,
androidTestBundle.testApplication
)
is TestRunFailed -> {
val logcat = listener.stop()
//Happens on Android Wear if classpath is misconfigured
if (event.error.contains("Process crashed")) {
throw TestAnnotationProducerNotFoundException(
instrumentationInfo.instrumentationPackage,
androidTestBundle.testApplication,
logcat,
)
}
}
}

is TestRunStopped -> Unit
is TestRunEnded -> Unit
is TestRunStopped -> Unit
is TestRunEnded -> Unit
}
}
}
}
}
listener.finish()

if (!observedAnnotations) {
logger.warn {
"Bundle ${bundle.id} did not report any test annotations. If you need test annotations retrieval, remote test parser requires additional setup " +
"see https://docs.marathonlabs.io/runner/android/configure#test-parser"
if (!observedAnnotations) {
logger.warn {
"Bundle ${bundle.id} did not report any test annotations. If you need test annotations retrieval, remote test parser requires additional setup " +
"see https://docs.marathonlabs.io/runner/android/configure#test-parser"
}
}
}

tests
tests
}
} finally {
listener.stop()
}
}
}

private class TestAnnotationProducerNotFoundException(val instrumentationPackage: String, val testApplication: File) : RuntimeException()
private class TestAnnotationProducerNotFoundException(val instrumentationPackage: String, val testApplication: File, val logcat: String) :
RuntimeException()
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ class LogcatManager : CoroutineScope {
val parser = LogCatMessageParser()
for (logPart in logcatChannel) {
val messages = parser.processLogLines(logPart.lines(), device)
messages.forEach { msg ->
device.onLine("${msg.timestamp} ${msg.pid}-${msg.tid}/${msg.appName} ${msg.logLevel.priorityLetter}/${msg.tag}: ${msg.message}")
}
messages.forEach { msg -> device.onLogcat(msg) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.malinskiy.marathon.android.adam.log

import com.malinskiy.marathon.android.logcat.LogcatListener
import com.malinskiy.marathon.android.logcat.LogcatProducer

class LogcatAccumulatingListener(
private val logcatProducer: LogcatProducer,
) : LogcatListener {
private val stringBuffer = StringBuffer(4096)
private val allowlist = setOf("AndroidJUnitRunner", "AndroidRuntime")

override suspend fun onMessage(msg: LogCatMessage) {
if (allowlist.contains(msg.tag)) {
when (msg.logLevel) {
Log.LogLevel.ERROR, Log.LogLevel.WARN -> stringBuffer.appendLine("${msg.logLevel.priorityLetter}/${msg.tag}: ${msg.message}\"")
else -> Unit
}
}
}

fun setup() {
logcatProducer.addLogcatListener(this)
}

fun finish() {
logcatProducer.removeLogcatListener(this)
}

fun start() {
stringBuffer.reset()
}

fun stop(): String {
val log = stringBuffer.toString()
stringBuffer.reset()
return log
}

private fun StringBuffer.reset() {
delete(0, length)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.malinskiy.marathon.android.logcat

import com.malinskiy.marathon.android.adam.log.LogCatMessage

interface LogcatListener : AutoCloseable {
suspend fun onMessage(message: LogCatMessage)

override fun close() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.malinskiy.marathon.android.logcat

interface LogcatProducer {
fun addLogcatListener(listener: LogcatListener)
fun removeLogcatListener(listener: LogcatListener)
}

0 comments on commit 63444ca

Please sign in to comment.