From ff74a7e6f3b19f6ece2cc9c66934bd50a73862fe Mon Sep 17 00:00:00 2001 From: Anton Malinskiy Date: Wed, 14 Aug 2024 18:26:17 +1000 Subject: [PATCH] feat(android): rework app installation + parser (#966) --- buildSrc/src/main/kotlin/Versions.kt | 2 +- .../marathon/android/AndroidAppInstaller.kt | 41 ++-- .../android/adam/AdamAndroidDevice.kt | 31 +++ .../android/adam/AmInstrumentTestParser.kt | 226 ++++++++++-------- 4 files changed, 178 insertions(+), 122 deletions(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 8da2ce7f4..0462c8533 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -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" diff --git a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/AndroidAppInstaller.kt b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/AndroidAppInstaller.kt index 96387d3d9..d4c97bae0 100644 --- a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/AndroidAppInstaller.kt +++ b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/AndroidAppInstaller.kt @@ -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 @@ -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}" } } /** diff --git a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AdamAndroidDevice.kt b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AdamAndroidDevice.kt index f321bd268..277b2e57d 100644 --- a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AdamAndroidDevice.kt +++ b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AdamAndroidDevice.kt @@ -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 @@ -306,6 +307,36 @@ class AdamAndroidDevice( absolutePath: String, reinstall: Boolean, optionalParams: List + ): 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 + ): 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 ): MarathonShellCommandResult { val file = File(absolutePath) //Very simple escaping for the name of the file diff --git a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AmInstrumentTestParser.kt b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AmInstrumentTestParser.kt index fa1f6de36..4ff29790c 100644 --- a/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AmInstrumentTestParser.kt +++ b/vendor/vendor-android/src/main/kotlin/com/malinskiy/marathon/android/adam/AmInstrumentTestParser.kt @@ -49,146 +49,168 @@ class AmInstrumentTestParser( override suspend fun extract(device: Device): List { 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() + 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, + bundle: AndroidTestBundle, blockListenerArgumentOverride: Boolean, - ): List { - val androidAppInstaller = AndroidAppInstaller(configuration) - androidAppInstaller.prepareInstallation(device) + ): MutableSet { + 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 = 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 = 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() - while (!channel.isClosedForReceive && isActive) { - val events: List? = 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() + while (!channel.isClosedForReceive && isActive) { + val events: List? = 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() +