From 1d0a0ba5d384d80e481fbff57644f25d6f9045d5 Mon Sep 17 00:00:00 2001 From: Marcel Schnelle Date: Sat, 4 Nov 2023 12:01:51 +0900 Subject: [PATCH 1/2] Add extensions module & introduce GrantPermissionExtension to it --- .circleci/config.yml | 13 ++- build-logic/src/main/kotlin/Environment.kt | 86 ++++++++------- ...> Extensions__Run_Unit_Tests__Gradle_.xml} | 9 +- instrumentation/CHANGELOG.md | 1 + instrumentation/extensions/api/extensions.api | 10 ++ instrumentation/extensions/build.gradle.kts | 93 ++++++++++++++++ .../extensions/src/main/AndroidManifest.xml | 1 + .../extensions/GrantPermissionExtension.kt | 69 ++++++++++++ .../GrantPermissionExtensionTests.kt | 103 ++++++++++++++++++ instrumentation/settings.gradle.kts | 1 + 10 files changed, 342 insertions(+), 44 deletions(-) rename instrumentation/.idea/runConfigurations/{Core__Run_Unit_Tests__Gradle_.xml => Extensions__Run_Unit_Tests__Gradle_.xml} (57%) create mode 100644 instrumentation/extensions/api/extensions.api create mode 100644 instrumentation/extensions/build.gradle.kts create mode 100644 instrumentation/extensions/src/main/AndroidManifest.xml create mode 100644 instrumentation/extensions/src/main/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtension.kt create mode 100644 instrumentation/extensions/src/test/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtensionTests.kt diff --git a/.circleci/config.yml b/.circleci/config.yml index 5e91d6c3..d94686f8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ defaults: &defaults GRADLE_OPTS: -Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dkotlin.compiler.execution.strategy=in-process -Dkotlin.incremental=false cache_key: &cache_key - key: jars-{{ checksum "plugin/build.gradle.kts" }}-{{ checksum "plugin/android-junit5/build.gradle.kts" }}-{{ checksum "plugin/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "instrumentation/build.gradle.kts" }}-{{ checksum "instrumentation/runner/build.gradle.kts" }}-{{ checksum "instrumentation/sample/build.gradle.kts" }}-{{ checksum "instrumentation/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "build-logic/src/main/kotlin/Environment.kt" }}-{{ checksum "build-logic/src/main/kotlin/Dependencies.kt" }} + key: jars-{{ checksum "plugin/build.gradle.kts" }}-{{ checksum "plugin/android-junit5/build.gradle.kts" }}-{{ checksum "plugin/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "instrumentation/build.gradle.kts" }}-{{ checksum "instrumentation/core/build.gradle.kts" }}-{{ checksum "instrumentation/compose/build.gradle.kts" }}-{{ checksum "instrumentation/extensions/build.gradle.kts" }}-{{ checksum "instrumentation/runner/build.gradle.kts" }}-{{ checksum "instrumentation/sample/build.gradle.kts" }}-{{ checksum "instrumentation/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "build-logic/src/main/kotlin/Environment.kt" }}-{{ checksum "build-logic/src/main/kotlin/Dependencies.kt" }} commands: construct_signing_key: @@ -42,11 +42,12 @@ jobs: ./gradlew assembleRelease :core:assembleDebug \ :core:assembleDebugAndroidTest \ :compose:assembleDebugAndroidTest \ + :extensions:assembleDebug \ :runner:assembleDebug \ - :sample:assembleDebug --stacktrace + :sample:assembleDebug --stacktrace - run: name: (Instrumentation) Test - command: cd instrumentation && ./gradlew :core:check :runner:check :compose:check --stacktrace + command: cd instrumentation && ./gradlew :core:check :extensions:check :runner:check :compose:check --stacktrace - save_cache: <<: *cache_key @@ -95,6 +96,9 @@ jobs: - store_artifacts: path: test-lab-results destination: instrumentation-core/test-lab-results + - store_artifacts: + path: instrumentation/extensions/build/reports + destination: instrumentation-extensions - store_artifacts: path: instrumentation/runner/build/reports destination: instrumentation-runner @@ -126,6 +130,9 @@ jobs: - store_artifacts: path: instrumentation/core/build/publications destination: instrumentation-core/publications/snapshots + - store_artifacts: + path: instrumentation/extensions/build/publications + destination: instrumentation-extensions/publications/snapshots - store_artifacts: path: instrumentation/runner/build/publications destination: instrumentation-runner/publications/snapshots diff --git a/build-logic/src/main/kotlin/Environment.kt b/build-logic/src/main/kotlin/Environment.kt index 61c42a60..8d777823 100644 --- a/build-logic/src/main/kotlin/Environment.kt +++ b/build-logic/src/main/kotlin/Environment.kt @@ -2,11 +2,11 @@ import Platform.Android import Platform.Java import org.gradle.api.Project import java.io.File -import java.util.* +import java.util.Properties enum class SupportedAgp( - val version: String, - val gradle: String? = null + val version: String, + val gradle: String? = null ) { AGP_7_0("7.0.4", gradle = "7.0.2"), AGP_7_1("7.1.3", gradle = "7.2"), @@ -55,13 +55,13 @@ sealed class Platform(val name: String) { * containing all sorts of configuration related to Maven coordinates, for instance. */ class Deployed internal constructor( - val platform: Platform, - val groupId: String, - val artifactId: String, - val currentVersion: String, - val latestStableVersion: String, - val description: String, - val license: String + val platform: Platform, + val groupId: String, + val artifactId: String, + val currentVersion: String, + val latestStableVersion: String, + val description: String, + val license: String ) object Artifacts { @@ -74,24 +74,24 @@ object Artifacts { * Return null if none can be found */ fun from(project: Project) = - when (project.name) { - "core" -> Instrumentation.Core - "runner" -> Instrumentation.Runner - "android-junit5" -> Plugin - else -> null - } + when (project.name) { + "core" -> Instrumentation.Core + "runner" -> Instrumentation.Runner + "android-junit5" -> Plugin + else -> null + } /** * Gradle Plugin artifact */ val Plugin = Deployed( - platform = Java, - groupId = "de.mannodermaus.gradle.plugins", - artifactId = "android-junit5", - currentVersion = "1.10.0.0-SNAPSHOT", - latestStableVersion = "1.9.3.0", - license = license, - description = "Unit Testing with JUnit 5 for Android." + platform = Java, + groupId = "de.mannodermaus.gradle.plugins", + artifactId = "android-junit5", + currentVersion = "1.10.0.0-SNAPSHOT", + latestStableVersion = "1.9.3.0", + license = license, + description = "Unit Testing with JUnit 5 for Android." ) /** @@ -103,23 +103,33 @@ object Artifacts { const val latestStableVersion = "1.3.0" val Core = Deployed( - platform = Android(minSdk = 14), - groupId = groupId, - artifactId = "android-test-core", - currentVersion = currentVersion, - latestStableVersion = latestStableVersion, - license = license, - description = "Extensions for instrumented Android tests with JUnit 5." + platform = Android(minSdk = 14), + groupId = groupId, + artifactId = "android-test-core", + currentVersion = currentVersion, + latestStableVersion = latestStableVersion, + license = license, + description = "Extensions for instrumented Android tests with JUnit 5." + ) + + val Extensions = Deployed( + platform = Android(minSdk = 14), + groupId = groupId, + artifactId = "android-test-extensions", + currentVersion = currentVersion, + latestStableVersion = latestStableVersion, + license = license, + description = "Optional extensions for instrumented Android tests with JUnit 5." ) val Runner = Deployed( - platform = Android(minSdk = 14), - groupId = groupId, - artifactId = "android-test-runner", - currentVersion = currentVersion, - latestStableVersion = latestStableVersion, - license = license, - description = "Runner for integration of instrumented Android tests with JUnit 5." + platform = Android(minSdk = 14), + groupId = groupId, + artifactId = "android-test-runner", + currentVersion = currentVersion, + latestStableVersion = latestStableVersion, + license = license, + description = "Runner for integration of instrumented Android tests with JUnit 5." ) val Compose = Deployed( @@ -166,5 +176,5 @@ class DeployedCredentials(private val project: Project) { } private fun Properties.getOrEnvvar(key: String): String? = - getProperty(key, System.getenv(key)) + getProperty(key, System.getenv(key)) } diff --git a/instrumentation/.idea/runConfigurations/Core__Run_Unit_Tests__Gradle_.xml b/instrumentation/.idea/runConfigurations/Extensions__Run_Unit_Tests__Gradle_.xml similarity index 57% rename from instrumentation/.idea/runConfigurations/Core__Run_Unit_Tests__Gradle_.xml rename to instrumentation/.idea/runConfigurations/Extensions__Run_Unit_Tests__Gradle_.xml index afe4fcfa..23830d08 100644 --- a/instrumentation/.idea/runConfigurations/Core__Run_Unit_Tests__Gradle_.xml +++ b/instrumentation/.idea/runConfigurations/Extensions__Run_Unit_Tests__Gradle_.xml @@ -1,5 +1,5 @@ - + - true + true + true + false + false \ No newline at end of file diff --git a/instrumentation/CHANGELOG.md b/instrumentation/CHANGELOG.md index 113ef970..df309f5b 100644 --- a/instrumentation/CHANGELOG.md +++ b/instrumentation/CHANGELOG.md @@ -8,6 +8,7 @@ Change Log - Only autoconfigure JUnit 5 for instrumentation tests when the user explicitly adds junit-jupiter-api as a dependency - Prevent noisy logs in Logcat complaining about unresolvable annotation classes (#306) - Add support for parallel execution of non-UI instrumentation tests (#295) +- Introduce `android-test-extensions` artifact with optional extensions, starting with a port of JUnit 4's `GrantPermissionRule` (#251) ## 1.3.0 (2021-09-17) diff --git a/instrumentation/extensions/api/extensions.api b/instrumentation/extensions/api/extensions.api new file mode 100644 index 00000000..09c30ed4 --- /dev/null +++ b/instrumentation/extensions/api/extensions.api @@ -0,0 +1,10 @@ +public final class de/mannodermaus/junit5/extensions/GrantPermissionExtension : org/junit/jupiter/api/extension/BeforeEachCallback { + public static final field Companion Lde/mannodermaus/junit5/extensions/GrantPermissionExtension$Companion; + public fun beforeEach (Lorg/junit/jupiter/api/extension/ExtensionContext;)V + public static final fun grant ([Ljava/lang/String;)Lde/mannodermaus/junit5/extensions/GrantPermissionExtension; +} + +public final class de/mannodermaus/junit5/extensions/GrantPermissionExtension$Companion { + public final fun grant ([Ljava/lang/String;)Lde/mannodermaus/junit5/extensions/GrantPermissionExtension; +} + diff --git a/instrumentation/extensions/build.gradle.kts b/instrumentation/extensions/build.gradle.kts new file mode 100644 index 00000000..4866db3e --- /dev/null +++ b/instrumentation/extensions/build.gradle.kts @@ -0,0 +1,93 @@ +import libs.plugins.android +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +buildscript { + repositories { + google() + mavenCentral() + sonatypeSnapshots() + } + + dependencies { + val latest = Artifacts.Plugin.latestStableVersion + classpath("de.mannodermaus.gradle.plugins:android-junit5:$latest") + } +} + +plugins { + id("com.android.library") + kotlin("android") + id("explicit-api-mode") +} + +apply { + plugin("de.mannodermaus.android-junit5") +} + +val javaVersion = JavaVersion.VERSION_1_8 + +android { + compileSdk = Android.compileSdkVersion + + defaultConfig { + minSdk = Android.testRunnerMinSdkVersion + targetSdk = Android.targetSdkVersion + } + + compileOptions { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } + + buildFeatures { + buildConfig = false + resValues = false + } + + lint { + // JUnit 4 refers to java.lang.management APIs, which are absent on Android. + warning("InvalidPackage") + } + + packagingOptions { + resources.excludes.add("META-INF/LICENSE.md") + resources.excludes.add("META-INF/LICENSE-notice.md") + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +tasks.withType { + kotlinOptions.jvmTarget = javaVersion.toString() +} + +tasks.withType { + failFast = true + testLogging { + events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) + exceptionFormat = TestExceptionFormat.FULL + } +} + +configurations.all { + // The Instrumentation Test Runner uses the plugin, + // which in turn provides the Instrumentation Test Runner again - + // that's kind of deep. + // To avoid conflicts, prefer using the local classes + // and exclude the dependency from being pulled in externally. + exclude(module = Artifacts.Instrumentation.Extensions.artifactId) +} + +dependencies { + implementation(libs.androidXTestRunner) + implementation(libs.junitJupiterApi) + + testImplementation(project(":testutil")) + testRuntimeOnly(libs.junitJupiterEngine) +} + +project.configureDeployment(Artifacts.Instrumentation.Extensions) diff --git a/instrumentation/extensions/src/main/AndroidManifest.xml b/instrumentation/extensions/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3b196a14 --- /dev/null +++ b/instrumentation/extensions/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/instrumentation/extensions/src/main/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtension.kt b/instrumentation/extensions/src/main/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtension.kt new file mode 100644 index 00000000..ff5e905a --- /dev/null +++ b/instrumentation/extensions/src/main/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtension.kt @@ -0,0 +1,69 @@ +package de.mannodermaus.junit5.extensions + +import android.Manifest +import android.os.Build +import androidx.annotation.VisibleForTesting +import androidx.test.internal.platform.ServiceLoaderWrapper.loadSingleService +import androidx.test.internal.platform.content.PermissionGranter +import androidx.test.runner.permission.PermissionRequester +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +/** + * The [GrantPermissionExtension] allows granting of runtime permissions on Android M (API 23) + * and above. Use this extension when a test requires a runtime permission to do its work. + * + * This is a port of JUnit 4's GrantPermissionRule for JUnit 5. + * + *

When applied to a test class it attempts to grant all requested runtime permissions. + * The requested permissions will then be granted on the device and will take immediate effect. + * Permissions can only be requested on Android M (API 23) or above and will be ignored on all other + * API levels. Once a permission is granted it will apply for all tests running in the current + * Instrumentation. There is no way of revoking a permission after it was granted. Attempting to do + * so will crash the Instrumentation process. + */ +public class GrantPermissionExtension +internal constructor(private val permissionGranter: PermissionGranter) : BeforeEachCallback { + + public companion object { + /** + * Static factory method that grants the requested [permissions]. + * + *

Permissions will be granted before any methods annotated with [BeforeEach] but before + * any test method execution. + * + * @see android.Manifest.permission + */ + @JvmStatic + public fun grant(vararg permissions: String): GrantPermissionExtension { + val granter = loadSingleService(PermissionGranter::class.java, ::PermissionRequester) + + return GrantPermissionExtension(granter).also { + it.grantPermissions(permissions) + } + } + + private fun satisfyPermissionDependencies(permissions: Array): Set { + val set = LinkedHashSet(permissions.size + 1).also { it.addAll(permissions) } + + // Grant READ_EXTERNAL_STORAGE implicitly if its counterpart is present + if (Build.VERSION.SDK_INT >= 16 && Manifest.permission.WRITE_EXTERNAL_STORAGE in set) { + set.add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + return set + } + } + + internal fun grantPermissions(permissions: Array) { + val permissionSet = satisfyPermissionDependencies(permissions) + permissionGranter.addPermissions(*permissionSet.toTypedArray()) + } + + /* BeforeEachCallback */ + + override fun beforeEach(context: ExtensionContext?) { + permissionGranter.requestPermissions() + } +} diff --git a/instrumentation/extensions/src/test/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtensionTests.kt b/instrumentation/extensions/src/test/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtensionTests.kt new file mode 100644 index 00000000..f1a264c3 --- /dev/null +++ b/instrumentation/extensions/src/test/kotlin/de/mannodermaus/junit5/extensions/GrantPermissionExtensionTests.kt @@ -0,0 +1,103 @@ +package de.mannodermaus.junit5.extensions + +import android.Manifest +import android.os.Build +import androidx.test.internal.platform.content.PermissionGranter +import com.google.common.truth.Truth.assertThat +import de.mannodermaus.junit5.testutil.AndroidBuildUtils.withApiLevel +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import java.lang.reflect.Modifier + +class GrantPermissionExtensionTests { + + private val granter = TestPermissionGranter() + + @Test + fun `single permission`() { + runExtension(Manifest.permission.CAMERA) + + assertThat(granter.grantedPermissions) + .containsExactly(Manifest.permission.CAMERA) + } + + @Test + fun `multiple permissions`() { + runExtension( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + + assertThat(granter.grantedPermissions) + .containsExactly( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ).inOrder() + } + + @TestFactory + fun `implicit addition of READ_EXTERNAL_STORAGE`(): List { + // Run this test for every available Android OS version. + // For each version below API 16, no implicit addition of permissions should be done + val latestApi = findLatestAndroidApiLevel() + val thresholdApi = 16 + + return (1..latestApi).map { api -> + val shouldAddPermission = api >= thresholdApi + + dynamicTest("API $api") { + withApiLevel(api) { + runExtension(Manifest.permission.WRITE_EXTERNAL_STORAGE) + + if (shouldAddPermission) { + assertThat(granter.grantedPermissions) + .containsExactly( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + ).inOrder() + } else { + assertThat(granter.grantedPermissions) + .containsExactly(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + } + } + } + + /* Private */ + + private fun findLatestAndroidApiLevel(): Int { + // Look inside Build.VERSION_CODES and locate + // the static int field with the highest value, + // except for the 'CUR_DEVELOPMENT' test field + return Build.VERSION_CODES::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) } + .filter { it.type == Int::class.java } + .filter { it.name != "CUR_DEVELOPMENT" } + .maxOf { it.get(null) as Int } + } + + private fun runExtension(vararg permissions: String) { + val extension = GrantPermissionExtension(granter) + extension.grantPermissions(permissions) + extension.beforeEach(null) + } + + private class TestPermissionGranter : PermissionGranter { + private val pending = mutableSetOf() + private val granted = mutableSetOf() + + override fun addPermissions(vararg permissions: String) { + pending.addAll(permissions) + } + + override fun requestPermissions() { + granted.addAll(pending) + pending.clear() + } + + val grantedPermissions: Set = granted + } +} diff --git a/instrumentation/settings.gradle.kts b/instrumentation/settings.gradle.kts index c46eeb04..a14fc660 100644 --- a/instrumentation/settings.gradle.kts +++ b/instrumentation/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "android-junit5-instrumentation" includeBuild("../build-logic") include(":core") include(":compose") +include(":extensions") include(":runner") include(":sample") include(":testutil") From 4d0d087ce23873cd8f3ea045f38285035f1f0292 Mon Sep 17 00:00:00 2001 From: Marcel Schnelle Date: Sat, 4 Nov 2023 23:12:05 +0900 Subject: [PATCH 2/2] Remove deployment switch for Compose vs non-Compose No longer needed since the minimum AGP has been bumped to 7 globally. To work around the enforced single version per project by the Nexus Publishing plugin, bump the Compose artifact to the same 1.4.0-SNAPSHOT line as the other instrumentation artifacts, f it --- build-logic/src/main/kotlin/Deployment.kt | 21 --------------------- build-logic/src/main/kotlin/Environment.kt | 2 +- build-logic/src/main/kotlin/Utilities.kt | 9 --------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/build-logic/src/main/kotlin/Deployment.kt b/build-logic/src/main/kotlin/Deployment.kt index fe205578..a9455dfa 100644 --- a/build-logic/src/main/kotlin/Deployment.kt +++ b/build-logic/src/main/kotlin/Deployment.kt @@ -25,17 +25,6 @@ fun Project.configureDeployment(deployConfig: Deployed) { throw IllegalStateException("This method can not be called on the root project") } - // Deployment of modules needs to be conditionally locked. - // If the project is set to Compose mode, only the Compose modules may be deployed. - // On the other hand, if the project is set to Default mode, only the ordinary - // instrumentation modules are deployed. This has to do with the restrictions - // of the Nexus Publishing plugin, which must use the same group and version declaration - // for all modules. It's impossible to use Version A for instrumentation modules and Version B - // for Compose modules at the same time, hence this conditional. - if (shouldSkipDeployment(deployConfig)) { - return - } - val credentials = DeployedCredentials(this) // Configure root project (this only happens once @@ -127,16 +116,6 @@ fun Project.configureDeployment(deployConfig: Deployed) { /* Private */ -private fun Project.shouldSkipDeployment(deployConfig: Deployed): Boolean { - return if (this.isComposeIncluded) { - // If Compose is included, any non-compose module should be skipped - deployConfig != Artifacts.Instrumentation.Compose - } else { - // If Compose is disabled, any compose module should be skipped - deployConfig == Artifacts.Instrumentation.Compose - } -} - private fun Project.configureRootDeployment(deployConfig: Deployed, credentials: DeployedCredentials) { if (this != rootProject) { throw IllegalStateException("This method can only be called on the root project") diff --git a/build-logic/src/main/kotlin/Environment.kt b/build-logic/src/main/kotlin/Environment.kt index 8d777823..524b6e93 100644 --- a/build-logic/src/main/kotlin/Environment.kt +++ b/build-logic/src/main/kotlin/Environment.kt @@ -136,7 +136,7 @@ object Artifacts { platform = Android(minSdk = 21), groupId = groupId, artifactId = "android-test-compose", - currentVersion = "1.0.0-SNAPSHOT", + currentVersion = currentVersion, latestStableVersion = "1.0.0-SNAPSHOT", license = license, description = "Extensions for Jetpack Compose tests with JUnit 5." diff --git a/build-logic/src/main/kotlin/Utilities.kt b/build-logic/src/main/kotlin/Utilities.kt index 2d869ee5..b6fd7380 100644 --- a/build-logic/src/main/kotlin/Utilities.kt +++ b/build-logic/src/main/kotlin/Utilities.kt @@ -77,15 +77,6 @@ fun Project.findLocalPluginJar(): File? { return localPluginJar } -/** - * Returns whether or not the Compose library module is included in the project. - * This depends on the presence of the :compose module, which is configured - * in settings.gradle. - */ -val Project.isComposeIncluded: Boolean get() { - return findProject(":compose") != null -} - /* File */ /**