ci: Instrumentation tests coverage in ci.
Some checks failed
Check PR code / BuildAndTestAndCoverage (pull_request) Failing after 30m24s

This commit is contained in:
Amine Bouabdallaoui 2025-03-16 15:55:10 +01:00
parent 02d503e03a
commit 6e52799e70
14 changed files with 577 additions and 151 deletions

View File

@ -0,0 +1,65 @@
name: Coverage
on:
workflow_call:
jobs:
BuildAndTestAndCoverage:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags -p
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- uses: gradle/actions/setup-gradle@v3
- uses: android-actions/setup-android@v3
- name: Configure gradle...
run: mkdir -p ~/.gradle && echo "ignoreGitVersion=true" >> ~/.gradle/gradle.properties
- uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: "2.23.3"
- name: run selfoss
run: |
docker compose -f .gitea/workflows/assets/docker-compose.yml up -d
- name: Set env url
run: |
export SELFOSS_URL=172.17.0.1:8888
# https://github.com/ReactiveCircus/android-emulator-runner/issues/385
- name: Kill crashpad_handler processes
if: always()
run: |
pkill -SIGTERM crashpad_handler || true
sleep 5
pkill -SIGKILL crashpad_handler || true
- name: Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: |
./gradlew androidApp:connectedAndroidTest
killall -INT crashpad_handler || true
- uses: actions/upload-artifact@v3
if: failure()
with:
name: failure-espresso
path: build/reports/androidTests/connected/screenshots
retention-days: 2
overwrite: true
include-hidden-files: true
- uses: actions/upload-artifact@v3
with:
name: coverage-espresso
path: build/reports/coverage/androidTest/githubConfig/debug/connected
retention-days: 1
overwrite: true
include-hidden-files: true
- name: Clean
if: always()
run: |
docker compose -f .gitea/workflows/assets/docker-compose.yml stop

View File

@ -3,89 +3,152 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
- chore-crowdin-ci
jobs: jobs:
Lint: BuildAndTestAndCoverage:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- name: Install klint
run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/
- name: Install detekt
run: curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.7/detekt-cli-1.23.7.zip && unzip detekt-cli-1.23.7.zip
- name: Linting...
run: ktlint 'shared/**/*.kt' 'androidApp/**/*.kt' '!shared/build'
- name: Detecting...
run: ./detekt-cli-1.23.7/bin/detekt-cli -c detekt.yml --excludes '**/shared/build/**/*.kt'
translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: "Check translations changes" - name: Fetch tags
id: check-translations-changes run: git fetch --tags -p
uses: tj-actions/changed-files@v45 - uses: actions/setup-java@v4
with: with:
files: | distribution: 'temurin'
androidApp/src/main/res/values/strings.xml java-version: '17'
- name: upload translation sources - uses: gradle/actions/setup-gradle@v3
if: steps.check-api-changes.outputs.any_modified == 'true' - uses: android-actions/setup-android@v3
uses: crowdin/github-action@v2 - name: Configure gradle...
run: mkdir -p ~/.gradle && echo "ignoreGitVersion=true" >> ~/.gradle/gradle.properties
- uses: KengoTODA/actions-setup-docker-compose@v1
with: with:
config: './.gitea/workflows/assets/crowdin.yml' version: "2.23.3"
upload_sources: true - name: run selfoss
upload_translations: false
download_translations: false
create_pull_request: false
push_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: wait
if: steps.check-api-changes.outputs.any_modified == 'true'
run: sleep 10s
- name: download translations
if: steps.check-api-changes.outputs.any_modified == 'true'
uses: crowdin/github-action@v2
with:
config: './.gitea/workflows/assets/crowdin.yml'
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Check for uncommitted changes
if: steps.check-api-changes.outputs.any_modified == 'true'
id: check-changes
uses: mskri/check-uncommitted-changes-action@v1.0.1
- name: Commit Changes
if: steps.check-api-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != ''
run: | run: |
git config --global user.email aminecmi+giteadrone@pm.me docker compose -f .gitea/workflows/assets/docker-compose.yml up -d
git config --global user.name giteadrone - name: Set env url
git add ./androidApp/src/main/res/* run: |
git commit -m "translation: translation files" export SELFOSS_URL=172.17.0.1:8888
- name: Push changes # https://github.com/ReactiveCircus/android-emulator-runner/issues/385
if: steps.check-api-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != '' - name: Kill crashpad_handler processes
uses: appleboy/git-push-action@v1.0.0 if: always()
run: |
pkill -SIGTERM crashpad_handler || true
sleep 5
pkill -SIGKILL crashpad_handler || true
- name: Tests
uses: reactivecircus/android-emulator-runner@v2
with: with:
author_name: giteadrone api-level: 29
author_email: aminecmi+giteadrone@pm.me cores: 6
remote: ${{ secrets.REMOTE_URL }} script: |
ssh_key: ${{ secrets.PRIVATE_KEY }} ./gradlew androidApp:clearScreenshotsTask || true
branch: ${{ github.head_ref || github.ref_name }} ./gradlew androidApp:createScreenshotDirectory
build: ./gradlew JacocoDebugCodeCoverage || true
needs: Lint adb logcat *:S TestRunner:V -d > androidApp/build/reports/androidTests/connected/screenshots/logs.txt
uses: ./.gitea/workflows/common_build.yml ./gradlew androidApp:fetchScreenshots
- uses: actions/upload-artifact@v3
if: always()
with:
name: failure-espresso
path: androidApp/build/reports/androidTests/connected/screenshots
retention-days: 2
overwrite: true
include-hidden-files: true
- uses: actions/upload-artifact@v3
with:
name: coverage-espresso
path: androidApp/build/reports/jacoco/JacocoDebugCodeCoverage
retention-days: 1
overwrite: true
include-hidden-files: true
- name: Clean
if: always()
run: |
docker compose -f .gitea/workflows/assets/docker-compose.yml stop
# Lint:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v4
# - uses: actions/setup-java@v4
# with:
# distribution: 'temurin'
# java-version: '17'
# cache: gradle
# - name: Install klint
# run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/
# - name: Install detekt
# run: curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.7/detekt-cli-1.23.7.zip && unzip detekt-cli-1.23.7.zip
# - name: Linting...
# run: ktlint 'shared/**/*.kt' 'androidApp/**/*.kt' '!shared/build'
# - name: Detecting...
# run: ./detekt-cli-1.23.7/bin/detekt-cli -c detekt.yml --excludes '**/shared/build/**/*.kt'
# translations:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: "Check translations changes"
# id: check-translations-changes
# uses: tj-actions/changed-files@v45
# with:
# files: |
# androidApp/src/main/res/values/strings.xml
# - name: upload translation sources
# if: steps.check-api-changes.outputs.any_modified == 'true'
# uses: crowdin/github-action@v2
# with:
# config: './.gitea/workflows/assets/crowdin.yml'
# upload_sources: true
# upload_translations: false
# download_translations: false
# create_pull_request: false
# push_translations: false
# env:
# CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# - name: wait
# if: steps.check-api-changes.outputs.any_modified == 'true'
# run: sleep 10s
# - name: download translations
# if: steps.check-api-changes.outputs.any_modified == 'true'
# uses: crowdin/github-action@v2
# with:
# config: './.gitea/workflows/assets/crowdin.yml'
# upload_sources: false
# upload_translations: false
# download_translations: true
# create_pull_request: false
# push_translations: false
# env:
# CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# - name: Check for uncommitted changes
# if: steps.check-api-changes.outputs.any_modified == 'true'
# id: check-changes
# uses: mskri/check-uncommitted-changes-action@v1.0.1
# - name: Commit Changes
# if: steps.check-api-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != ''
# run: |
# git config --global user.email aminecmi+giteadrone@pm.me
# git config --global user.name giteadrone
# git add ./androidApp/src/main/res/*
# git commit -m "translation: translation files"
# - name: Push changes
# if: steps.check-api-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != ''
# uses: appleboy/git-push-action@v1.0.0
# with:
# author_name: giteadrone
# author_email: aminecmi+giteadrone@pm.me
# remote: ${{ secrets.REMOTE_URL }}
# ssh_key: ${{ secrets.PRIVATE_KEY }}
# branch: ${{ github.head_ref || github.ref_name }}
# build:
# needs: Lint
# uses: ./.gitea/workflows/common_build.yml

View File

@ -10,6 +10,7 @@ plugins {
id("com.mikepenz.aboutlibraries.plugin") id("com.mikepenz.aboutlibraries.plugin")
id("org.jetbrains.kotlinx.kover") id("org.jetbrains.kotlinx.kover")
id("app.cash.sqldelight") version "2.0.2" id("app.cash.sqldelight") version "2.0.2"
jacoco
} }
fun Project.execWithOutput( fun Project.execWithOutput(
@ -64,6 +65,15 @@ fun versionNameFromGit(): String {
return gitVersion() return gitVersion()
} }
val exclusions =
listOf(
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
)
android { android {
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
@ -95,7 +105,7 @@ android {
// tests // tests
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true" testInstrumentationRunnerArguments["useTestStorageService"] = "true"
} }
packaging { packaging {
resources { resources {
@ -109,6 +119,44 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
} }
getByName("debug") { getByName("debug") {
isTestCoverageEnabled = true
enableAndroidTestCoverage = true
installation {
installOptions("-g", "-r")
}
val androidTests = "connectedAndroidTest"
tasks.register<JacocoReport>("JacocoDebugCodeCoverage") {
// Depend on unit tests and Android tests tasks
dependsOn(listOf(androidTests))
// Set task grouping and description
group = "Reporting"
description = "Execute UI and unit tests, generate and combine Jacoco coverage report"
// Configure reports to generate both XML and HTML formats
reports {
xml.required.set(true)
html.required.set(true)
}
// Set source directories to the main source directory
sourceDirectories.setFrom(layout.projectDirectory.dir("src/main"))
// Set class directories to compiled Java and Kotlin classes, excluding specified exclusions
classDirectories.setFrom(
files(
fileTree(layout.buildDirectory.dir("intermediates/javac/")) {
exclude(exclusions)
},
fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/")) {
exclude(exclusions)
},
),
)
// Collect execution data from .exec and .ec files generated during test execution
executionData.setFrom(
files(
fileTree(layout.buildDirectory) { include(listOf("**/*.exec", "**/*.ec")) },
),
)
}
} }
} }
flavorDimensions.add("build") flavorDimensions.add("build")
@ -121,7 +169,6 @@ android {
namespace = "bou.amine.apps.readerforselfossv2.android" namespace = "bou.amine.apps.readerforselfossv2.android"
testOptions { testOptions {
animationsDisabled = true animationsDisabled = true
execution = "ANDROIDX_TEST_ORCHESTRATOR"
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
@ -154,8 +201,8 @@ dependencies {
implementation("androidx.multidex:multidex:2.0.1") implementation("androidx.multidex:multidex:2.0.1")
// About // About
implementation("com.mikepenz:aboutlibraries-core:10.5.1") implementation("com.mikepenz:aboutlibraries-core:11.6.3")
implementation("com.mikepenz:aboutlibraries:10.5.1") implementation("com.mikepenz:aboutlibraries:11.6.3")
// Material-ish things // Material-ish things
implementation("com.ashokvarma.android:bottom-navigation-bar:2.2.0") implementation("com.ashokvarma.android:bottom-navigation-bar:2.2.0")
@ -197,14 +244,15 @@ dependencies {
testImplementation("io.mockk:mockk:1.13.14") testImplementation("io.mockk:mockk:1.13.14")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test:runner:1.7.0-alpha01")
androidTestImplementation("androidx.test:rules:1.6.1") androidTestImplementation("androidx.test:rules:1.7.0-alpha01")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
implementation("androidx.test.espresso:espresso-idling-resource:3.6.1") implementation("androidx.test.espresso:espresso-idling-resource:3.6.1")
androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestUtil("androidx.test:orchestrator:1.5.1") androidTestUtil("androidx.test.services:test-services:1.6.0-alpha02")
testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core-ktx:1.6.1") testImplementation("androidx.test:core-ktx:1.7.0-alpha01")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
implementation("ch.acra:acra-http:$acraVersion") implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-toast:$acraVersion") implementation("ch.acra:acra-toast:$acraVersion")
@ -227,6 +275,7 @@ tasks.withType<Test> {
} }
aboutLibraries { aboutLibraries {
excludeFields = arrayOf("generated")
offlineMode = true offlineMode = true
fetchRemoteLicense = false fetchRemoteLicense = false
fetchRemoteFunding = false fetchRemoteFunding = false
@ -235,3 +284,39 @@ aboutLibraries {
duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
duplicationRule = com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP duplicationRule = com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
} }
val clearScreenshotsTask =
tasks.register<Exec>("clearScreenshots") {
println("AMINE : clear")
commandLine = listOf("adb", "shell", "rm", "-r", "/storage/emulated/0/Pictures/selfoss_tests/screenshots/*")
}
val createScreenshotDirectoryTask =
tasks.register<Exec>("createScreenshotDirectory") {
println("AMINE : create directory")
group = "reporting"
commandLine = listOf("adb", "shell", "mkdir", "-p", "/storage/emulated/0/Pictures/selfoss_tests/screenshots")
}
tasks.register<Exec>("fetchScreenshots") {
val reportsDirectory = file("$buildDir/reports/androidTests/connected")
println("AMINE : fetch")
group = "reporting"
executable(android.adbExecutable.toString())
commandLine = listOf("adb", "pull", "/storage/emulated/0/Pictures/selfoss_tests/screenshots", reportsDirectory.toString())
finalizedBy(clearScreenshotsTask)
doFirst {
reportsDirectory.mkdirs()
}
}
tasks.withType(Test::class) {
if (this.name == "connectedAndroidTest") {
configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
}

View File

@ -15,13 +15,17 @@ import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class LoginActivityTest { class `1-LoginActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@ -40,7 +44,7 @@ class LoginActivityTest {
} }
@Test @Test
fun viewIsInitialized() { fun `1-viewIsInitialized`() {
onView(withId(R.id.urlView)).check(matches(isDisplayed())) onView(withId(R.id.urlView)).check(matches(isDisplayed()))
onView(withId(R.id.selfSigned)) onView(withId(R.id.selfSigned))
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
@ -57,28 +61,28 @@ class LoginActivityTest {
} }
@Test @Test
fun urlError() { fun `2-urlError`() {
performLogin("10.0.2.2:8888") performLogin("10.0.2.2:8888")
onView(withId(R.id.urlView)).perform(click()) onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem))) onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem)))
} }
@Test @Test
fun connectError() { fun `3-urlSlashError`() {
performLogin("http://10.0.2.2:8889")
onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.wrong_infos)))
}
@Test
fun urlSlashError() {
performLogin("https://google.fr/toto") performLogin("https://google.fr/toto")
onView(withId(R.id.urlView)).perform(click()) onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem))) onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem)))
} }
@Test @Test
fun multiError() { fun `4-connectError`() {
performLogin("http://10.0.2.2:8889")
onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.wrong_infos)))
}
@Test
fun `5-multiError`() {
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
@ -86,8 +90,9 @@ class LoginActivityTest {
} }
@Test @Test
fun connect() { fun `6-connect`() {
performLogin() performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed())) onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
onView(withText("OK")).perform(click())
} }
} }

View File

@ -15,21 +15,19 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.Before import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class HomeActivityTest { class `2-HomeActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@Before
fun init() {
loginAndInitHome()
}
@Test @Test
fun testMenu() { fun testMenu() {

View File

@ -19,9 +19,11 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityTest { @Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
class `3-SettingsActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
lateinit var context: Context lateinit var context: Context
@Before @Before
@ -29,7 +31,6 @@ class SettingsActivityTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome()
openMenu() openMenu()
onView(withText(R.string.title_activity_settings)).perform(click()) onView(withText(R.string.title_activity_settings)).perform(click())
} }
@ -68,6 +69,9 @@ class SettingsActivityTest {
changeAndSaveSetting("", "10") { changeAndSaveSetting("", "10") {
onView(withText(R.string.pref_api_timeout)).perform(click()) onView(withText(R.string.pref_api_timeout)).perform(click())
} }
changeAndSaveSetting("", "60") {
onView(withText(R.string.pref_api_timeout)).perform(click())
}
} }
@Test @Test

View File

@ -22,19 +22,22 @@ import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class SettingsActivityGeneralTest { class `4-SettingsActivityGeneralTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@Before @Before
fun init() { fun init() {
loginAndInitHome()
openActionBarOverflowOrOptionsMenu( openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext(), ApplicationProvider.getApplicationContext(),
) )

View File

@ -1,29 +1,32 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView 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.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityReaderTest { class `5-SettingsActivityReaderTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(SettingsActivity::class.java)
lateinit var context: Context lateinit var context: Context
@ -32,14 +35,14 @@ class SettingsActivityReaderTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext(),
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_viewer)).perform(click()) onView(withText(R.string.pref_header_viewer)).perform(click())
} }
@After
fun back() {
onView(isRoot()).perform(ViewActions.pressBack())
}
@Test @Test
fun testReader() { fun testReader() {
onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).check( onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).check(

View File

@ -1,31 +1,34 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView 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.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityOfflineTest { class `6-SettingsActivityOfflineTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(SettingsActivity::class.java)
lateinit var context: Context lateinit var context: Context
@ -34,14 +37,14 @@ class SettingsActivityOfflineTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext(),
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_offline)).perform(click()) onView(withText(R.string.pref_header_offline)).perform(click())
} }
@After
fun back() {
onView(isRoot()).perform(ViewActions.pressBack())
}
@Suppress("detekt:LongMethod") @Suppress("detekt:LongMethod")
@Test @Test
fun testOffline() { fun testOffline() {

View File

@ -21,11 +21,12 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.UUID import java.util.UUID
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SourcesActivityTest { class `7-SourcesActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
lateinit var sourceName: String lateinit var sourceName: String
@ -33,7 +34,6 @@ class SourcesActivityTest {
fun init() { fun init() {
sourceName = UUID.randomUUID().toString().substring(0, 15) sourceName = UUID.randomUUID().toString().substring(0, 15)
loginAndInitHome()
goToSources() goToSources()
} }
@ -75,10 +75,4 @@ class SourcesActivityTest {
onView(withId(android.R.id.button1)).perform(click()) onView(withId(android.R.id.button1)).perform(click())
onView(withText(sourceName)).check(doesNotExist()) onView(withText(sourceName)).check(doesNotExist())
} }
private fun goToSources() {
openMenu()
onView(withText(R.string.menu_home_sources))
.perform(click())
}
} }

View File

@ -1,7 +1,12 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Environment.DIRECTORY_PICTURES
import android.os.Environment.getExternalStoragePublicDirectory
import android.util.Log
import androidx.annotation.ArrayRes import androidx.annotation.ArrayRes
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
@ -9,29 +14,36 @@ import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.base.DefaultFailureHandler
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Matchers.hasToString import org.hamcrest.Matchers.hasToString
import org.junit.BeforeClass
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
val defaultUrl = (System.getenv("SELFOSS_URL") ?: "").ifEmpty { "http://10.0.2.2:8888" }
fun performLogin(someUrl: String? = null) { fun performLogin(someUrl: String? = null) {
Log.i("AUTOMATION", "The url used will be ${if (!someUrl.isNullOrEmpty()) someUrl else defaultUrl}")
onView(withId(R.id.urlView)).perform(click()).perform( onView(withId(R.id.urlView)).perform(click()).perform(
typeTextIntoFocusedView( typeTextIntoFocusedView(
if (!someUrl.isNullOrEmpty()) someUrl else "http://10.0.2.2:8888", if (!someUrl.isNullOrEmpty()) someUrl else defaultUrl,
), ),
) )
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
} }
fun loginAndInitHome() {
performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
onView(withText("OK")).perform(click())
}
fun changeAndCancelSetting( fun changeAndCancelSetting(
oldValue: String, oldValue: String,
newValue: String, newValue: String,
@ -97,6 +109,12 @@ fun testPreferencesFromArray(
} }
} }
fun goToSources() {
openMenu()
onView(withText(R.string.menu_home_sources))
.perform(click())
}
fun testAddSourceWithUrl( fun testAddSourceWithUrl(
url: String, url: String,
sourceName: String, sourceName: String,
@ -119,3 +137,88 @@ fun testAddSourceWithUrl(
.perform(click()) .perform(click())
onView(withText(sourceName)).check(matches(isDisplayed())) onView(withText(sourceName)).check(matches(isDisplayed()))
} }
@Suppress("detekt:UtilityClassWithPublicConstructor")
open class WithANRException {
companion object {
// 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:",
)
private val otherException = "System Ul isn't responding"
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()
}
@JvmStatic
@BeforeClass
fun setUpHandler() {
Espresso.setFailureHandler { error, viewMatcher ->
takeScreenshot()
if (error.message!!.contains(otherException)) {
handleAnrDialogue()
} else if (error.message!!.contains(rootViewWithoutFocusExceptionMsg) &&
anrCount < 20
) {
anrCount++
handleAnrDialogue()
} else { // chain all failures down to the default espresso handler
Log.e("AMINE", "AMINE : ${error.message}")
println("AMINE : ${error.message}")
DefaultFailureHandler(getInstrumentation().targetContext).handle(error, viewMatcher)
}
}
}
}
}
fun takeScreenshot() {
try {
val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
val folder =
File(
File(
getExternalStoragePublicDirectory(DIRECTORY_PICTURES),
"selfoss_tests",
).absolutePath,
"screenshots",
)
if (!folder.exists()) {
folder.mkdirs()
}
var out: BufferedOutputStream? = null
val size = folder.list().size + 1
try {
out = BufferedOutputStream(FileOutputStream(folder.path + "/" + size + ".png"))
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
Log.d("Screenshots", "Screenshot taken")
} catch (e: IOException) {
Log.e("Screenshots", "Could not save the screenshot", e)
} finally {
if (out != null) {
try {
out.close()
} catch (e: IOException) {
Log.e("Screenshots", "Could not save the screenshot", e)
}
}
}
} catch (ex: IOException) {
Log.e("Screenshots", "Could not take the screenshot", ex)
}
}

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MyApp"
android:allowBackup="false"
android:configChanges="uiMode"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/NoBar"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".LoginActivity"
android:label="@string/title_activity_login"></activity>
<activity android:name=".HomeActivity"></activity>
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings"
android:parentActivityName=".HomeActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".HomeActivity" />
</activity>
<activity
android:name=".SourcesActivity"
android:parentActivityName=".HomeActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".HomeActivity" />
</activity>
<activity
android:name=".UpsertSourceActivity"
android:exported="true"
android:parentActivityName=".SourcesActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".SourcesActivity" />
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity android:name=".ReaderActivity"></activity>
<activity
android:name=".ImageActivity"
android:theme="@style/Theme.AppCompat.ImageActivity"></activity>
<meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="true" />
<meta-data
android:name="android.max_aspect"
android:value="2.1" />
</application>
</manifest>

View File

@ -27,3 +27,4 @@ org.gradle.caching=true
ignoreGitVersion=false ignoreGitVersion=false
kotlin.native.cacheKind.iosX64=none kotlin.native.cacheKind.iosX64=none
org.gradle.configureondemand=true org.gradle.configureondemand=true
kotlin.jvm.target.validation.mode=IGNORE

View File

@ -224,16 +224,25 @@ class Repository(
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled() appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true
if (shouldFetch && connectivityService.isNetworkAvailable()) { if (shouldFetch && connectivityService.isNetworkAvailable()) {
val apiSources = api.sourcesDetailed() sources = sourceDetails(isDatabaseEnabled)
if (apiSources.success && apiSources.data != null) {
fetchedSources = true
sources = apiSources.data
if (isDatabaseEnabled) {
resetDBSourcesWithData(sources)
}
}
} else if (isDatabaseEnabled) { } else if (isDatabaseEnabled) {
sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.SourceDetail> sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.SourceDetail>
if (sources.isEmpty() && !connectivityService.isNetworkAvailable() && !fetchedSources) {
sources = sourceDetails(isDatabaseEnabled)
}
}
return sources
}
private suspend fun sourceDetails(isDatabaseEnabled: Boolean): ArrayList<SelfossModel.SourceDetail> {
var sources = ArrayList<SelfossModel.SourceDetail>()
val apiSources = api.sourcesDetailed()
if (apiSources.success && apiSources.data != null) {
fetchedSources = true
sources = apiSources.data
if (isDatabaseEnabled) {
resetDBSourcesWithData(sources)
}
} }
return sources return sources
} }
@ -371,6 +380,7 @@ class Repository(
): Boolean { ): Boolean {
var response = false var response = false
if (connectivityService.isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
fetchedSources = false
response = api response = api
.createSourceForVersion( .createSourceForVersion(
title, title,
@ -392,6 +402,7 @@ class Repository(
): Boolean { ): Boolean {
var response = false var response = false
if (connectivityService.isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
fetchedSources = false
response = api.updateSourceForVersion(id, title, url, spout, tags).isSuccess == true response = api.updateSourceForVersion(id, title, url, spout, tags).isSuccess == true
} }
@ -406,6 +417,7 @@ class Repository(
if (connectivityService.isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val response = api.deleteSource(id) val response = api.deleteSource(id)
success = response.isSuccess success = response.isSuccess
fetchedSources = false
} }
// We filter on success or if the network isn't available // We filter on success or if the network isn't available