From 4baf0cb93b2061058831dbf20c2d3910b85eb68f Mon Sep 17 00:00:00 2001 From: Jason Elie Bou Kheir <5115126+jasonboukheir@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:05:46 -0700 Subject: [PATCH] test(android): add instrumentation test github action (#4178) Fixes #2311 --- .github/workflows/_kotlin.yml | 70 +++++++- kotlin/android/app/build.gradle.kts | 19 ++- .../firezone/android/core/HiltTestRunner.kt | 17 ++ .../dev/firezone/android/core/ViewActions.kt | 56 +++++++ .../android/core/di/TestRuntimeModule.kt | 18 ++ .../core/presentation/MainActivityTest.kt | 157 ++++++++++++++++++ .../firezone/android/core/ApplicationMode.kt | 7 + .../firezone/android/core/di/RuntimeModule.kt | 15 ++ .../features/splash/ui/SplashViewModel.kt | 4 +- 9 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 kotlin/android/app/src/androidTest/java/dev/firezone/android/core/HiltTestRunner.kt create mode 100644 kotlin/android/app/src/androidTest/java/dev/firezone/android/core/ViewActions.kt create mode 100644 kotlin/android/app/src/androidTest/java/dev/firezone/android/core/di/TestRuntimeModule.kt create mode 100644 kotlin/android/app/src/androidTest/java/dev/firezone/android/core/presentation/MainActivityTest.kt create mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/core/ApplicationMode.kt create mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/core/di/RuntimeModule.kt diff --git a/.github/workflows/_kotlin.yml b/.github/workflows/_kotlin.yml index 3343da6a1..2e9d726f0 100644 --- a/.github/workflows/_kotlin.yml +++ b/.github/workflows/_kotlin.yml @@ -3,6 +3,10 @@ on: workflow_call: workflow_dispatch: +defaults: + run: + working-directory: ./kotlin/android + permissions: contents: 'read' id-token: 'write' @@ -11,9 +15,6 @@ jobs: static-analysis: # Android SDK tools hardware accel is available only on Linux runners runs-on: ubuntu-22.04 - defaults: - run: - working-directory: ./kotlin/android steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -33,9 +34,6 @@ jobs: build: # Android SDK tools hardware accel is available only on Linux runners runs-on: ubuntu-22.04 - defaults: - run: - working-directory: ./kotlin/android steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-rust @@ -59,9 +57,8 @@ jobs: KEYSTORE_PATH=$(pwd)/app/keystore.jks echo -n "$KEYSTORE_BASE64" | base64 --decode > $KEYSTORE_PATH ./gradlew bundleRelease - - name: Run Test + - name: Run Unit Test run: | - # TODO: See https://github.com/firezone/firezone/issues/2311 ./gradlew testReleaseUnitTest - name: Upload app bundle uses: actions/upload-artifact@v4 @@ -78,3 +75,60 @@ jobs: run: | echo -n "$FIREBASE_APP_DISTRIBUTION_CREDENTIALS" > $FIREBASE_CREDENTIALS_PATH ./gradlew --info appDistributionUploadRelease uploadCrashlyticsSymbolFileRelease + + ui-test: + name: ui-test-api-${{ matrix.api-level }} + strategy: + fail-fast: false + matrix: + include: + - api-level: 26 + - api-level: 29 + # Android SDK tools hardware accel is available only on Linux runners + runs-on: ubuntu-22.04 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: false + swap-storage: true + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-rust + with: + targets: armv7-linux-androideabi aarch64-linux-android x86_64-linux-android i686-linux-android + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + - name: Run Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew --stacktrace connectedCheck + working-directory: ./kotlin/android diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index 7dffd925c..7e33ea646 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -58,7 +58,7 @@ android { // mark:automatic-version versionName = "1.0.2" multiDexEnabled = true - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "dev.firezone.android.core.HiltTestRunner" } signingConfigs { @@ -146,6 +146,12 @@ android { buildFeatures { viewBinding = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -172,6 +178,16 @@ dependencies { implementation("com.google.dagger:hilt-android:2.51.1") kapt("androidx.hilt:hilt-compiler:1.2.0") kapt("com.google.dagger:hilt-android-compiler:2.51.1") + // Instrumented Tests + androidTestImplementation("com.google.dagger:hilt-android-testing:2.51") + kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1") + androidTestImplementation("androidx.test:runner:1.5.2") + androidTestImplementation("androidx.navigation:navigation-testing:2.7.7") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") + // Unit Tests + testImplementation("com.google.dagger:hilt-android-testing:2.51") // Retrofit 2 implementation("com.squareup.retrofit2:retrofit:2.11.0") @@ -194,6 +210,7 @@ dependencies { // JUnit testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.fragment:fragment-testing:1.6.2") // Import the BoM for the Firebase platform implementation(platform("com.google.firebase:firebase-bom:32.8.0")) diff --git a/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/HiltTestRunner.kt b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/HiltTestRunner.kt new file mode 100644 index 000000000..059b5706f --- /dev/null +++ b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/HiltTestRunner.kt @@ -0,0 +1,17 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context?, + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/ViewActions.kt b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/ViewActions.kt new file mode 100644 index 000000000..bdbb85e52 --- /dev/null +++ b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/ViewActions.kt @@ -0,0 +1,56 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core + +import android.view.View +import androidx.test.espresso.PerformException +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.util.HumanReadables +import androidx.test.espresso.util.TreeIterables +import org.hamcrest.Matcher +import org.hamcrest.StringDescription +import java.util.concurrent.TimeoutException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +fun waitForView( + vararg matchers: Matcher, + timeout: Duration = 5.seconds, +): ViewAction { + return object : ViewAction { + private val timeoutMillis = timeout.inWholeMilliseconds + + override fun getConstraints() = isRoot() + + override fun getDescription(): String { + val subDescription = StringDescription() + matchers.forEach { it.describeTo(subDescription) } + return "Wait for a view matching one of: $subDescription; with a timeout of $timeout." + } + + override fun perform( + uiController: UiController, + rootView: View, + ) { + uiController.loopMainThreadUntilIdle() + val startTime = System.currentTimeMillis() + val endTime = startTime + timeoutMillis + + do { + for (child in TreeIterables.breadthFirstViewTraversal(rootView)) { + if (matchers.any { matcher -> matcher.matches(child) }) { + return + } + } + uiController.loopMainThreadForAtLeast(100) + } while (System.currentTimeMillis() < endTime) + + throw PerformException.Builder() + .withCause(TimeoutException()) + .withActionDescription(this.description) + .withViewDescription(HumanReadables.describe(rootView)) + .build() + } + } +} diff --git a/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/di/TestRuntimeModule.kt b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/di/TestRuntimeModule.kt new file mode 100644 index 000000000..2f81856ef --- /dev/null +++ b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/di/TestRuntimeModule.kt @@ -0,0 +1,18 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import dev.firezone.android.core.ApplicationMode + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [RuntimeModule::class], +) +object TestRuntimeModule { + @Provides + internal fun provideApplicationMode() = ApplicationMode.TESTING +} diff --git a/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/presentation/MainActivityTest.kt b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/presentation/MainActivityTest.kt new file mode 100644 index 000000000..7fa604ef5 --- /dev/null +++ b/kotlin/android/app/src/androidTest/java/dev/firezone/android/core/presentation/MainActivityTest.kt @@ -0,0 +1,157 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core.presentation + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.base.DefaultFailureHandler +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dev.firezone.android.R +import dev.firezone.android.core.waitForView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf +import org.hamcrest.TypeSafeMatcher +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +@LargeTest +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class MainActivityTest { + // Running count of the number of Android Not Responding dialogues to prevent endless dismissal. + private var anrCount = 0 + + // `RootViewWithoutFocusException` class is private, need to match the message (instead of using type matching). + private val rootViewWithoutFocusExceptionMsg = + java.lang.String.format( + Locale.ROOT, + "Waited for the root of the view hierarchy to have " + + "window focus and not request layout for 10 seconds. If you specified a non " + + "default root matcher, it may be picking a root that never takes focus. " + + "Root:", + ) + + @get:Rule(order = 0) + internal var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + internal var activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + + @Before + fun init() { + val context = ApplicationProvider.getApplicationContext() + Espresso.setFailureHandler { error, viewMatcher -> + if (error.message!!.contains(rootViewWithoutFocusExceptionMsg) && anrCount < 3) { + anrCount++ + handleAnrDialogue() + } else { // chain all failures down to the default espresso handler + DefaultFailureHandler(context).handle(error, viewMatcher) + } + } + hiltRule.inject() + } + + @Test + fun mainActivityTest() { + val btSettingsMatchers = + allOf( + withId(R.id.btSettings), + withText("Settings"), + isDisplayed(), + ) + val btLogsMatchers = + allOf( + withContentDescription("Logs"), + childAtPosition( + childAtPosition( + withId(R.id.tabLayout), + 0, + ), + 1, + ), + isDisplayed(), + ) + val btAdvancedMatchers = + allOf( + withContentDescription("Advanced"), + childAtPosition( + childAtPosition( + withId(R.id.tabLayout), + 0, + ), + 0, + ), + isDisplayed(), + ) + val btSaveSettingsMatchers = + allOf( + withId(R.id.btSaveSettings), + withText("Save"), + childAtPosition( + childAtPosition( + withId(android.R.id.content), + 0, + ), + 2, + ), + isDisplayed(), + ) + onView(isRoot()).perform(waitForView(btSettingsMatchers)) + onView(btSettingsMatchers).perform(click()) + + onView(isRoot()).perform(waitForView(btLogsMatchers)) + onView(btLogsMatchers).perform(click()) + + onView(isRoot()).perform(waitForView(btAdvancedMatchers)) + onView(btAdvancedMatchers).perform(click()) + + onView(isRoot()).perform(waitForView(btSaveSettingsMatchers)) + onView(btSaveSettingsMatchers).perform(click()) + } + + private fun handleAnrDialogue() { + val device = UiDevice.getInstance(getInstrumentation()) + // If running the device in English Locale + val waitButton = device.findObject(UiSelector().textContains("wait")) + if (waitButton.exists()) waitButton.click() + } + + private fun childAtPosition( + parentMatcher: Matcher, + position: Int, + ): Matcher { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("Child at position $position in parent ") + parentMatcher.describeTo(description) + } + + public override fun matchesSafely(view: View): Boolean { + val parent = view.parent + return parent is ViewGroup && parentMatcher.matches(parent) && + view == parent.getChildAt(position) + } + } + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/ApplicationMode.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/ApplicationMode.kt new file mode 100644 index 000000000..4a2da3339 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/ApplicationMode.kt @@ -0,0 +1,7 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core + +enum class ApplicationMode { + NORMAL, + TESTING, +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/RuntimeModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/RuntimeModule.kt new file mode 100644 index 000000000..f0ed0b928 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/RuntimeModule.kt @@ -0,0 +1,15 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.firezone.android.core.ApplicationMode + +@Module +@InstallIn(SingletonComponent::class) +object RuntimeModule { + @Provides + internal fun provideApplicationMode() = ApplicationMode.NORMAL +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt index af28cab13..26fa3c739 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.firezone.android.core.ApplicationMode import dev.firezone.android.core.data.Repository import dev.firezone.android.tunnel.TunnelService import kotlinx.coroutines.delay @@ -22,6 +23,7 @@ internal class SplashViewModel constructor( private val repo: Repository, private val applicationRestrictions: Bundle, + private val applicationMode: ApplicationMode, ) : ViewModel() { private val actionMutableLiveData = MutableLiveData() val actionLiveData: LiveData = actionMutableLiveData @@ -30,7 +32,7 @@ internal class SplashViewModel viewModelScope.launch { // Stay a while and enjoy the logo delay(REQUEST_DELAY) - if (!hasVpnPermissions(context)) { + if (!hasVpnPermissions(context) && applicationMode != ApplicationMode.TESTING) { actionMutableLiveData.postValue(ViewAction.NavigateToVpnPermission) } else { val token = applicationRestrictions.getString("token") ?: repo.getTokenSync()