diff --git a/.gitea/workflows/on_push_testing.yml b/.gitea/workflows/on_push_testing.yml index 9ca8aa2..7b979df 100644 --- a/.gitea/workflows/on_push_testing.yml +++ b/.gitea/workflows/on_push_testing.yml @@ -10,10 +10,10 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v4 -# with: -# fetch-depth: 0 -# - name: Fetch tags -# run: git fetch --tags -p + with: + fetch-depth: 0 + - name: Fetch tags + run: git fetch --tags -p - name: Init compose uses: KengoTODA/actions-setup-docker-compose@v1 with: @@ -21,20 +21,43 @@ jobs: - name: run selfoss run: | docker compose -f .gitea/workflows/assets/docker-compose.yml up -d + sleep 1m response=$(curl 172.17.0.1:8888/api/about) echo $response -# - uses: actions/setup-java@v4 -# with: -# distribution: 'temurin' -# java-version: '17' -# - name: Setup Android SDK -# uses: android-actions/setup-android@v3 -# - name: Configure gradle... -# run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true\nsystemProp.org.gradle.internal.http.connectionTimeout=180000\nsystemProp.org.gradle.internal.http.socketTimeout=180000" >> ~/.gradle/gradle.properties -# - name: Build and test -# run: ./gradlew build --stacktrace + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + - name: Configure gradle... + run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true\nsystemProp.org.gradle.internal.http.connectionTimeout=180000\nsystemProp.org.gradle.internal.http.socketTimeout=180000" >> ~/.gradle/gradle.properties + - name: ui tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 25 + target: aosp_atd + profile: Nexus 6 + force-avd-creation: false + script: ./gradlew connectedAndroidTest + - name: Artifacts + uses: actions/upload-artifact@v3 + if: failure() + with: + name: ui-tests + path: androidApp/build/outputs/androidTest-results/connected/debug/flavors/githubConfig + if-no-files-found: error + retention-days: 5 + overwrite: true + include-hidden-files: true + - name: Test Summary + uses: test-summary/action@v2 + with: + paths: | + androidApp/build/outputs/androidTest-results/connected/debug/flavors/githubConfig/**/TEST-*.xml + androidApp/build/test-results/testGithubConfig*/*.xml + if: failure() - name: Clean - if: success() || failure() + if: always() run: | - docker compose -f .gitea/workflows/assets/docker-compose.yml stop - + docker compose -f .gitea/workflows/assets/docker-compose.yml stop \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4bf20a4..d8f0e91 100644 --- a/.gitignore +++ b/.gitignore @@ -320,4 +320,6 @@ fabric.properties # End of https://www.toptal.com/developers/gitignore/api/gradle,kotlin,androidstudio,android,xcode,swift -crowdin.properties \ No newline at end of file +crowdin.properties + +.kotlin/ \ No newline at end of file diff --git a/androidApp/.gitignore b/androidApp/.gitignore index 796b96d..248e305 100644 --- a/androidApp/.gitignore +++ b/androidApp/.gitignore @@ -1 +1,2 @@ /build +.kotlin/ \ No newline at end of file diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c9349bd..11bf753 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -84,6 +84,7 @@ android { // tests testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" } packaging { resources { @@ -107,6 +108,10 @@ android { } } namespace = "bou.amine.apps.readerforselfossv2.android" + testOptions { + animationsDisabled = true + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } } @@ -184,6 +189,12 @@ dependencies { testImplementation("io.mockk:mockk:1.12.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") + androidTestUtil("androidx.test:orchestrator:1.5.1") + implementation("ch.acra:acra-http:$acraVersion") implementation("ch.acra:acra-toast:$acraVersion") diff --git a/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/CommonTests.kt b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/CommonTests.kt new file mode 100644 index 0000000..f77a182 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/CommonTests.kt @@ -0,0 +1,23 @@ +package bou.amine.apps.readerforselfossv2.android + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText + +fun performLogin(url: String) { + onView(withId(R.id.urlView)).perform(click()).perform( + typeTextIntoFocusedView(url) + ) + onView(withId(R.id.signInButton)).perform(click()) +} + +fun loginAndInitHome() { + performLogin("http://10.0.2.2:8888") + Thread.sleep(30000) + onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed())) + onView(withText("OK")).perform(click()) +} \ No newline at end of file diff --git a/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/HomeActivityTest.kt b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/HomeActivityTest.kt new file mode 100644 index 0000000..60bcb77 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/HomeActivityTest.kt @@ -0,0 +1,161 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isFocused +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.isSelected +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 org.hamcrest.CoreMatchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class HomeActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(LoginActivity::class.java) + + private fun getActivity(): Activity? { + var activity: Activity? = null + activityRule.scenario.onActivity { + activity = it + } + return activity + } + + @Before + fun init() { + loginAndInitHome() + } + + @Test + fun testMenu() { + onView(withId(R.id.action_search)).check(matches(isDisplayed())).check( + matches( + isClickable() + ) + ) + onView(withId(R.id.action_filter)).check(matches(isDisplayed())).check( + matches( + isClickable() + ) + ) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + onView(withText(R.string.readAll)).check(matches(isDisplayed())) + onView(withText(R.string.menu_home_sources)).check(matches(isDisplayed())) + onView(withText(R.string.title_activity_settings)).check(matches(isDisplayed())) + onView(withText(R.string.menu_home_refresh)).check(matches(isDisplayed())) + onView(withText(R.string.issue_tracker_link)).check(matches(isDisplayed())) + onView(withText(R.string.action_disconnect)).check(matches(isDisplayed())) + } + + @Test + fun testMenuActions() { + onView(withId(R.id.action_search)).perform(click()) + onView( + withId(R.id.search_src_text) + ).check(matches(isFocused())) + onView(isRoot()).perform(ViewActions.pressBack()) + + onView(withId(R.id.action_filter)).perform(click()) + onView( + withText(R.string.filter_item_sources) + ).check(matches(isDisplayed())) + onView( + withText(R.string.filter_item_tags) + ).check(matches(isDisplayed())) + onView( + withId(R.id.floatingActionButton2) + ).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + onView(withText(R.string.readAll)).perform(click()) + onView(withText(R.string.markall_dialog_message)).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + + onView(withText(R.string.menu_home_sources)).perform(click()) + onView(withId(R.id.fab)).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + + onView(withText(R.string.title_activity_settings)).perform(click()) + onView(withText(R.string.pref_header_general)).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + + onView(withText(R.string.menu_home_refresh)).perform(click()) + onView(withText(R.string.refresh_dialog_message)).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + + /*onView(withText(R.string.issue_tracker_link)).perform(click()) + onView(withText(R.string.markall_dialog_message)).check(matches(isDisplayed())) + onView(isRoot()).perform(ViewActions.pressBack()) + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + )*/ + + onView(withText(R.string.action_disconnect)).perform(click()) + onView(withText(R.string.confirm_disconnect_title)).check(matches(isDisplayed())) + } + + @Test + fun testEmptyView() { + onView(withId(R.id.emptyText)).check(matches(isDisplayed())) + onView( + hasBottombarItemText(R.string.tab_new) + ).check(matches(isDisplayed())).check(matches(isSelected())) + onView( + hasBottombarItemText(R.string.tab_read) + ).check(matches(isDisplayed())).check(matches(not(isSelected()))) + onView( + hasBottombarItemText(R.string.tab_favs) + ).check(matches(isDisplayed())).check(matches(not(isSelected()))) + } + + @Test + fun addSource() { + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + onView(withText(R.string.menu_home_sources)) + .perform(click()) + onView(withId(R.id.fab)) + .perform(click()) + onView(withId(R.id.nameInput)) + .perform(click()).perform(typeTextIntoFocusedView("Source")) + Thread.sleep(20000) + } + +} \ No newline at end of file diff --git a/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/LoginActivityTest.kt b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/LoginActivityTest.kt new file mode 100644 index 0000000..b26e83c --- /dev/null +++ b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/LoginActivityTest.kt @@ -0,0 +1,70 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.app.Activity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isNotChecked +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 org.hamcrest.CoreMatchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class LoginActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(LoginActivity::class.java) + + private fun getActivity(): Activity? { + var activity: Activity? = null + activityRule.scenario.onActivity { + activity = it + } + return activity + } + + @Test + fun viewIsInitialized() { + onView(withId(R.id.urlView)).check(matches(isDisplayed())) + onView(withId(R.id.selfSigned)).check(matches(isDisplayed())).check(matches(isNotChecked())) + .check( + matches(isClickable()) + ) + onView(withId(R.id.withLogin)).check(matches(isDisplayed())) + .check(matches(isNotChecked())).check( + matches(isClickable()) + ) + } + + @Test + fun urlError() { + performLogin("172.17.0.1:8888") + onView(withId(R.id.urlView)).perform(click()) + onView(withId(R.id.urlView)).check(matches(withError(getActivity()!!.getString(R.string.wrong_infos)))) + } + + @Test + fun multiError() { + onView(withId(R.id.signInButton)).perform(click()) + Thread.sleep(15000) + onView(withId(R.id.signInButton)).perform(click()) + Thread.sleep(15000) + onView(withId(R.id.signInButton)).perform(click()) + onView(withText(R.string.warning_wrong_url)).check(matches(isDisplayed())) + } + + @Test + fun connect() { + performLogin("http://172.17.0.1:8888") + onView(withId(R.id.loginProgress)).check(matches(isDisplayed())) + } +} \ No newline at end of file diff --git a/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/helpers.kt b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/helpers.kt new file mode 100644 index 0000000..f018513 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/helpers.kt @@ -0,0 +1,65 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.graphics.drawable.toBitmap +import androidx.test.espresso.Root +import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withResourceName +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + + +fun withError(expected: String): TypeSafeMatcher { + return object : TypeSafeMatcher() { + override fun matchesSafely(view: View?): Boolean { + if (view !is EditText) { + return false + } + return view.error.toString() == expected + } + + override fun describeTo(description: Description?) { + } + } +} + +fun isPopupWindow(): Matcher { + return isPlatformPopup() +} + +fun withDrawable(@DrawableRes id: Int) = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("ImageView with drawable same as drawable with id $id") + } + + override fun matchesSafely(view: View): Boolean { + val context = view.context + val expectedBitmap = context.getDrawable(id)!!.toBitmap() + try { + return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap) + } catch (e: Exception) { + return false + } + } +} + +fun hasBottombarItemText(@StringRes id: Int): Matcher? { + return allOf( + withResourceName("fixed_bottom_navigation_icon"), + withParent( + allOf( + withResourceName("fixed_bottom_navigation_icon_container"), + hasSibling(withText(id)) + ) + ) + ) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 28fdf63..d40bc34 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,6 +18,7 @@ kotlin.code.style=official #Android android.useAndroidX=true #android.nonTransitiveRClass=true +android.enableJetifier=true android.nonTransitiveRClass=false #MPP kotlin.mpp.enableCInteropCommonization=true