mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
test(android): add instrumentation test github action (#4178)
Fixes #2311
This commit is contained in:
committed by
GitHub
parent
678a27c491
commit
4baf0cb93b
70
.github/workflows/_kotlin.yml
vendored
70
.github/workflows/_kotlin.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<View>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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<View>,
|
||||
position: Int,
|
||||
): Matcher<View> {
|
||||
return object : TypeSafeMatcher<View>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
|
||||
package dev.firezone.android.core
|
||||
|
||||
enum class ApplicationMode {
|
||||
NORMAL,
|
||||
TESTING,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ViewAction>()
|
||||
val actionLiveData: LiveData<ViewAction> = 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()
|
||||
|
||||
Reference in New Issue
Block a user