Skip to content

Commit

Permalink
feat(android): rework app installation + parser (#966)
Browse files Browse the repository at this point in the history
  • Loading branch information
Malinskiy authored Aug 14, 2024
1 parent 4107a4e commit ff74a7e
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 122 deletions.
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ object Versions {
val coroutinesTest = coroutines

val androidCommon = "31.2.2"
val adam = "0.5.7"
val adam = "0.5.8"
val dexTestParser = "2.3.4"
val kotlinLogging = "3.0.5"
val logbackClassic = "1.4.14"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.malinskiy.marathon.android
import com.malinskiy.marathon.android.exception.DeviceNotSupportedException
import com.malinskiy.marathon.android.exception.InstallException
import com.malinskiy.marathon.android.extension.testBundlesCompat
import com.malinskiy.marathon.android.model.AndroidTestBundle
import com.malinskiy.marathon.config.Configuration
import com.malinskiy.marathon.config.vendor.VendorConfiguration
import com.malinskiy.marathon.exceptions.DeviceSetupException
Expand All @@ -26,31 +27,33 @@ class AndroidAppInstaller(configuration: Configuration) {
suspend fun prepareInstallation(device: AndroidDevice) {
val testBundles = androidConfiguration.testBundlesCompat()
testBundles.forEach { bundle ->
val apkParser = ApkParser()
val applicationInfo = apkParser.parseInstrumentationInfo(bundle.testApplication)
preparePartialInstallation(device, bundle)
}
}

logger.debug { "Installing application output to ${device.serialNumber}" }
bundle.application?.let { applicationApk ->
if (bundle.splitApks.isNullOrEmpty()) {
if (device.apiLevel < 21) {
throw DeviceNotSupportedException("Device api level should be more then 20")
}
suspend fun preparePartialInstallation(device: AndroidDevice, bundle: AndroidTestBundle) {
val applicationInfo = bundle.instrumentationInfo
logger.debug { "Installing application output to ${device.serialNumber}" }
bundle.application?.let { applicationApk ->
if (bundle.splitApks.isNullOrEmpty()) {
if (device.apiLevel < 21) {
throw DeviceNotSupportedException("Device api level should be more then 20")
}
reinstall(device, applicationInfo.applicationPackage, applicationApk, bundle.splitApks ?: emptyList())
}
reinstall(device, applicationInfo.applicationPackage, applicationApk, bundle.splitApks ?: emptyList())
}

bundle.extraApplications?.let { extraApplications ->
extraApplications.forEach { extraApplication ->
logger.debug { "Installing extra application to ${device.serialNumber}" }
val extraApplicationPackage = apkParser.parseAppPackageName(extraApplication)
reinstall(device, extraApplicationPackage, extraApplication)
}
bundle.extraApplications?.let { extraApplications ->
extraApplications.forEach { extraApplication ->
logger.debug { "Installing extra application to ${device.serialNumber}" }
val extraApplicationPackage = AndroidTestBundle.apkParser.parseAppPackageName(extraApplication)
reinstall(device, extraApplicationPackage, extraApplication)
}

logger.debug { "Installing instrumentation package to ${device.serialNumber}" }
reinstall(device, applicationInfo.instrumentationPackage, bundle.testApplication)
logger.debug { "Prepare installation finished for ${device.serialNumber}" }
}

logger.debug { "Installing instrumentation package to ${device.serialNumber}" }
reinstall(device, applicationInfo.instrumentationPackage, bundle.testApplication)
logger.debug { "Prepare installation finished for ${device.serialNumber}" }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.malinskiy.adam.request.framebuffer.BufferedImageScreenCaptureAdapter
import com.malinskiy.adam.request.framebuffer.ScreenCaptureRequest
import com.malinskiy.adam.request.pkg.InstallRemotePackageRequest
import com.malinskiy.adam.request.pkg.InstallSplitPackageRequest
import com.malinskiy.adam.request.pkg.StreamingPackageInstallRequest
import com.malinskiy.adam.request.pkg.UninstallRemotePackageRequest
import com.malinskiy.adam.request.pkg.multi.ApkSplitInstallationPackage
import com.malinskiy.adam.request.prop.GetPropRequest
Expand Down Expand Up @@ -306,6 +307,36 @@ class AdamAndroidDevice(
absolutePath: String,
reinstall: Boolean,
optionalParams: List<String>
): MarathonShellCommandResult {
return if (supportedFeatures.contains(Feature.ABB_EXEC) || supportedFeatures.contains(Feature.CMD)) {
installPackageStreaming(absolutePath, reinstall, optionalParams)
} else {
installPackageLegacy(absolutePath, reinstall, optionalParams)
}
}

private suspend fun installPackageStreaming(
absolutePath: String,
reinstall: Boolean,
optionalParams: List<String>
): MarathonShellCommandResult {
val result = withTimeoutOrNull(androidConfiguration.timeoutConfiguration.install) {
client.execute(
StreamingPackageInstallRequest(
File(absolutePath),
supportedFeatures,
reinstall,
extraArgs = optionalParams.filter { it.isNotBlank() },
), serial = adbSerial
)
} ?: throw InstallException("Timeout installing $absolutePath")
return com.malinskiy.marathon.android.model.ShellCommandResult(result.output, if (result.success) 0 else 1)
}

private suspend fun installPackageLegacy(
absolutePath: String,
reinstall: Boolean,
optionalParams: List<String>
): MarathonShellCommandResult {
val file = File(absolutePath)
//Very simple escaping for the name of the file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,146 +49,168 @@ class AmInstrumentTestParser(
override suspend fun extract(device: Device): List<Test> {
val testBundles = vendorConfiguration.testBundlesCompat()
var blockListenerArgumentOverride = false
return withRetry(3, 0) {
try {
val device = device as? AdamAndroidDevice ?: throw ConfigurationException("Unexpected device type for remote test parsing")
return@withRetry parseTests(device, configuration, vendorConfiguration, testBundles, blockListenerArgumentOverride)
} catch (e: CancellationException) {
throw e
} catch (e: TestAnnotationProducerNotFoundException) {
logger.warn {
"""Previous parsing attempt failed for ${e.instrumentationPackage}
val messageBuilder = StringBuilder()
messageBuilder.appendLine("Parsing bundle(s):")
testBundles.map { it.instrumentationInfo }.forEach {
messageBuilder.appendLine("- testPackage: ${it.instrumentationPackage}")
if (it.applicationPackage.isNotBlank()) {
messageBuilder.appendLine(" targetPackage: ${it.applicationPackage}")
}
}
logger.debug { messageBuilder.trimEnd().toString() }
/**
* We don't support duplicate tests from multiple bundles
*/
val result = mutableSetOf<Test>()
val adamDevice =
device as? AdamAndroidDevice ?: throw ConfigurationException("Unexpected device type for remote test parsing")
val androidAppInstaller = AndroidAppInstaller(configuration)

testBundles.forEach { bundle ->
withRetry(3, 0) {
try {
val bundleTests = parseTests(adamDevice, configuration, androidAppInstaller, vendorConfiguration, bundle, blockListenerArgumentOverride)
result.addAll(bundleTests)
return@withRetry
} catch (e: CancellationException) {
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.
Device log:
${e.logcat}
""".trimIndent()
}
blockListenerArgumentOverride = true
throw e
} catch (throwable: Throwable) {
logger.debug(throwable) { "Remote parsing failed. Retrying" }
throw throwable
}
blockListenerArgumentOverride = true
throw e
} catch (throwable: Throwable) {
logger.debug(throwable) { "Remote parsing failed. Retrying" }
throw throwable
}
}

return result.toList()
}

private suspend fun parseTests(
device: AdamAndroidDevice,
configuration: Configuration,
installer: AndroidAppInstaller,
vendorConfiguration: VendorConfiguration.AndroidConfiguration,
testBundles: List<AndroidTestBundle>,
bundle: AndroidTestBundle,
blockListenerArgumentOverride: Boolean,
): List<Test> {
val androidAppInstaller = AndroidAppInstaller(configuration)
androidAppInstaller.prepareInstallation(device)
): MutableSet<Test> {
installer.preparePartialInstallation(device, bundle)

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
}

else -> emptyMap()
val instrumentationInfo = bundle.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,
)
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)
}
else -> emptyMap()
}

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,
)
}
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, bundle)
}

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 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,
bundle.testApplication,
logcat,
)
}
}

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

is TestRunStopped -> Unit
is TestRunEnded -> Unit
}
}
}
listener.finish()
}
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
}

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

private class TestAnnotationProducerNotFoundException(val instrumentationPackage: String, val testApplication: File, val logcat: String) :
RuntimeException()
private class TestAnnotationProducerNotFoundException(
val instrumentationPackage: String,
val testApplication: File,
val logcat: String
) : RuntimeException()

0 comments on commit ff74a7e

Please sign in to comment.