From 2d83b383876afe952aca922c85da95e696ab0f3f Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester Date: Sat, 13 Apr 2024 16:01:18 +0200 Subject: [PATCH] test: main flow Closes #727 --- app/phone/build.gradle.kts | 8 ++ .../dev/jdtech/jellyfin/HiltTestRunner.kt | 16 ++++ .../dev/jdtech/jellyfin/MainActivityTest.kt | 95 +++++++++++++++++++ .../jellyfin/ViewPropertyChangeCallback.kt | 64 +++++++++++++ .../jdtech/jellyfin/di/DatabaseTestModule.kt | 29 ++++++ gradle/libs.versions.toml | 21 +++- 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt create mode 100644 app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt create mode 100644 app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt create mode 100644 app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts index 12cb6004..6abc2690 100644 --- a/app/phone/build.gradle.kts +++ b/app/phone/build.gradle.kts @@ -21,6 +21,8 @@ android { versionCode = Versions.appCode versionName = Versions.appName + + testInstrumentationRunner = "dev.jdtech.jellyfin.HiltTestRunner" } buildTypes { @@ -112,4 +114,10 @@ dependencies { implementation(libs.timber) coreLibraryDesugaring(libs.android.desugar.jdk) + + androidTestImplementation(libs.androidx.room.runtime) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.bundles.androidx.test) + androidTestImplementation(libs.hilt.android.testing) + kspTest(libs.hilt.android.compiler) } diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt new file mode 100644 index 00000000..e7f52dcb --- /dev/null +++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt @@ -0,0 +1,16 @@ +package dev.jdtech.jellyfin + +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/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt new file mode 100644 index 00000000..486b6759 --- /dev/null +++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt @@ -0,0 +1,95 @@ +package dev.jdtech.jellyfin + +import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import dev.jdtech.jellyfin.di.DatabaseModule +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +@UninstallModules(DatabaseModule::class) +class MainActivityTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Before + fun setUp() { + hiltRule.inject() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + val config = Configuration.Builder() + .setWorkerFactory(workerFactory) + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + @Test + fun testMainFlow() { + launchActivity().use { + // Wait for the app to load + waitForElement(allOf(withId(R.id.edit_text_server_address), isDisplayed())) + + // Connect to demo server + onView(withId(R.id.edit_text_server_address)).perform(typeText("https://demo.jellyfin.org/stable"), closeSoftKeyboard()) + onView(withId(R.id.button_connect)).perform(click()) + + // Connecting to the server + waitForElement(allOf(withId(R.id.edit_text_username), isDisplayed())) + + // Login + onView(withId(R.id.edit_text_username)).perform(typeText("demo"), closeSoftKeyboard()) + onView(withId(R.id.button_login)).perform(click()) + + // Navigate to My media + waitForElement(allOf(withText("Continue Watching"), isDisplayed())) + onView(withId(R.id.mediaFragment)).perform(click()) + + // Navigate to movies + waitForElement(allOf(withText("Movies"), isDisplayed())) + onView(withText("Movies")).perform(click()) + + // Navigate to Battle of the Stars + waitForElement(allOf(withText("Battle of the Stars"), isDisplayed())) + onView(withText("Battle of the Stars")).perform(click()) + + // Play the movie + waitForElement(allOf(withId(R.id.play_button), isEnabled())) + onView(withId(R.id.play_button)).perform(click()) + + // Wait for movie to start playing + waitForElement(allOf(withId(androidx.media3.ui.R.id.exo_buffering), isDisplayed())) + waitForElement(allOf(withId(androidx.media3.ui.R.id.exo_buffering), not(isDisplayed()))) + + // Navigate back + Espresso.pressBack() + } + } +} diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt new file mode 100644 index 00000000..a3a0c8b1 --- /dev/null +++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt @@ -0,0 +1,64 @@ +package dev.jdtech.jellyfin + +import android.view.View +import android.view.ViewTreeObserver +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import org.hamcrest.CoreMatchers +import org.hamcrest.Matcher +import org.hamcrest.StringDescription + +private class ViewPropertyChangeCallback(private val matcher: Matcher, private val view: View) : IdlingResource, ViewTreeObserver.OnDrawListener { + private lateinit var callback: IdlingResource.ResourceCallback + private var matched = false + + override fun getName() = "View property change callback" + + override fun isIdleNow() = matched + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { + this.callback = callback + } + + override fun onDraw() { + matched = matcher.matches(view) + callback.onTransitionToIdle() + } +} + +fun waitUntil(matcher: Matcher): ViewAction = object : ViewAction { + override fun getConstraints(): Matcher { + return CoreMatchers.any(View::class.java) + } + + override fun getDescription(): String { + return StringDescription().let { + matcher.describeTo(it) + "wait until: $it" + } + } + + override fun perform(uiController: UiController, view: View) { + if (!matcher.matches(view)) { + ViewPropertyChangeCallback(matcher, view).run { + try { + IdlingRegistry.getInstance().register(this) + view.viewTreeObserver.addOnDrawListener(this) + uiController.loopMainThreadUntilIdle() + } finally { + view.viewTreeObserver.removeOnDrawListener(this) + IdlingRegistry.getInstance().unregister(this) + } + } + } + } +} + +fun waitForElement(matcher: Matcher) { + onView(isRoot()).perform(waitUntil(hasDescendant(matcher))) +} diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt new file mode 100644 index 00000000..e5a367b1 --- /dev/null +++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt @@ -0,0 +1,29 @@ +package dev.jdtech.jellyfin.di + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.jdtech.jellyfin.database.ServerDatabase +import dev.jdtech.jellyfin.database.ServerDatabaseDao +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseTestModule { + @Singleton + @Provides + fun provideServerDatabaseDao(@ApplicationContext app: Context): ServerDatabaseDao { + return Room.inMemoryDatabaseBuilder( + app.applicationContext, + ServerDatabase::class.java, + ) + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + .getServerDatabaseDao() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6474f42..1bbaaab4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,18 @@ androidx-preference = "1.2.1" androidx-recyclerview = "1.3.2" androidx-room = "2.6.1" androidx-swiperefreshlayout = "1.1.0" +androidx-test-core = "1.5.0" +androidx-test-expresso = "3.5.1" +androidx-test-junit = "1.1.5" +androidx-test-rules = "1.5.0" +androidx-test-runner = "1.5.2" androidx-tv = "1.0.0-alpha10" androidx-work = "2.9.0" coil = "2.6.0" hilt = "2.51.1" compose-destinations = "1.10.2" jellyfin = "1.4.7" +junit = "4.13.2" kotlin = "1.9.23" kotlinx-serialization = "1.6.3" ksp = "1.9.23-1.0.20" @@ -66,17 +72,27 @@ androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview" androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } -androidx-work = { group = "androidx.work", name = "work-runtime", version.ref = "androidx-work" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test-core" } +androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidx-test-core" } +androidx-test-expresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-test-expresso"} +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } +androidx-test-rules = { group = "androidx.test" , name = "rules", version.ref = "androidx-test-rules" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "androidx-tv" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "androidx-tv" } +androidx-work = { group = "androidx.work", name = "work-runtime", version.ref = "androidx-work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidx-work" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } compose-destinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "compose-destinations" } compose-destinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "compose-destinations" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } jellyfin-core = { group = "org.jellyfin.sdk", name = "jellyfin-core", version.ref = "jellyfin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } libmpv = { group = "dev.jdtech.mpv", name = "libmpv", version.ref = "libmpv" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } media3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3-ffmpeg-decoder" } @@ -94,3 +110,6 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } + +[bundles] +androidx-test = ["androidx-test-core", "androidx-test-core-ktx", "androidx-test-expresso", "androidx-test-junit", "androidx-test-rules", "androidx-test-runner", "androidx-work-testing"] \ No newline at end of file