diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 648b522..2f62492 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,12 +20,32 @@ jobs: - name: Run tests env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew check coverageReport sonar --no-configuration-cache + run: ./gradlew check coverageReport sonar --no-configuration-cache --no-daemon - name: Publish test reports uses: mikepenz/action-junit-report@v3 if: always() # always run even if the previous step fails with: - report_paths: './**/build/test-results/**/TEST-*.xml' \ No newline at end of file + report_paths: './**/build/test-results/**/TEST-*.xml' + + uiTests: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Run tests + run: ./gradlew allDevicesCheck --no-daemon -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" \ No newline at end of file diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index a06d89e..78d10a6 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -21,13 +21,15 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + - name: Sign APK env: MEALIENT_KEY_STORE: ${{ secrets.MEALIENT_KEY_STORE }} MEALIENT_KEY_STORE_PASSWORD: ${{ secrets.MEALIENT_KEY_STORE_PASSWORD }} MEALIENT_KEY_ALIAS: ${{ secrets.MEALIENT_KEY_ALIAS }} MEALIENT_KEY_PASSWORD: ${{ secrets.MEALIENT_KEY_PASSWORD }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} APPSWEEP_API_KEY: ${{ secrets.APPSWEEP_API_KEY }} run: | @@ -36,7 +38,7 @@ jobs: echo "storePassword=$MEALIENT_KEY_STORE_PASSWORD" >> keystore.properties echo "keyAlias=$MEALIENT_KEY_ALIAS" >> keystore.properties echo "keyPassword=$MEALIENT_KEY_PASSWORD" >> keystore.properties - ./gradlew build bundle coverageReport sonar uploadToAppSweepRelease --no-configuration-cache + ./gradlew build bundle coverageReport sonar uploadToAppSweepRelease --no-configuration-cache --no-daemon cp app/build/outputs/apk/release/*.apk mealient-release.apk cp app/build/outputs/bundle/release/*.aab mealient-release.aab diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ac419b..273a884 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ @file:Suppress("UnstableApiUsage") +import com.android.build.api.dsl.ManagedVirtualDevice import java.io.FileInputStream import java.util.* @@ -17,6 +18,8 @@ android { applicationId = "gq.kirmanak.mealient" versionCode = 25 versionName = "0.3.10" + testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner" + testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true") } signingConfigs { @@ -54,6 +57,22 @@ android { packagingOptions { resources.excludes += "DebugProbesKt.bin" } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + testOptions { + managedDevices { + devices { + maybeCreate("pixel2api30").apply { + device = "Pixel 2" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + } + } + } } dependencies { @@ -89,6 +108,8 @@ dependencies { kapt(libs.google.dagger.hiltCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler) testImplementation(libs.google.dagger.hiltAndroidTesting) + kaptAndroidTest(libs.google.dagger.hiltAndroidCompiler) + androidTestImplementation(libs.google.dagger.hiltAndroidTesting) implementation(libs.androidx.paging.runtimeKtx) testImplementation(libs.androidx.paging.commonKtx) @@ -122,4 +143,13 @@ dependencies { testImplementation(libs.io.mockk) debugImplementation(libs.squareup.leakcanary) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.kaspersky.kaspresso) + androidTestImplementation(libs.okhttp3.mockwebserver) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestUtil(libs.androidx.test.orchestrator) } \ No newline at end of file diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..cfb2be6 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt new file mode 100644 index 0000000..7025845 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt @@ -0,0 +1,33 @@ +package gq.kirmanak.mealient + +import androidx.test.ext.junit.rules.activityScenarioRule +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import dagger.hilt.android.testing.HiltAndroidRule +import gq.kirmanak.mealient.ui.activity.MainActivity +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule + +abstract class BaseTestCase : TestCase() { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val mainActivityRule = activityScenarioRule() + + lateinit var mockWebServer: MockWebServer + + @Before + open fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt new file mode 100644 index 0000000..a6b5e96 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt @@ -0,0 +1,76 @@ +package gq.kirmanak.mealient + +import dagger.hilt.android.testing.HiltAndroidTest +import gq.kirmanak.mealient.screen.BaseUrlScreen +import gq.kirmanak.mealient.screen.DisclaimerScreen +import gq.kirmanak.mealient.screen.RecipesListScreen +import org.junit.Before +import org.junit.Test + +@HiltAndroidTest +class FirstSetUpTest : BaseTestCase() { + + @Before + fun dispatchUrls() { + mockWebServer.dispatch { url, _ -> + if (url == "/api/app/about") versionV1Response else notFoundResponse + } + } + + @Test + fun test() = run { + step("Ensure button is disabled") { + DisclaimerScreen { + okayButton { + isVisible() + isDisabled() + hasAnyText() + } + + disclaimerText { + isVisible() + hasText(R.string.fragment_disclaimer_main_text) + } + } + } + + step("Close disclaimer screen") { + DisclaimerScreen { + okayButton { + isVisible() + isEnabled() + hasText(R.string.fragment_disclaimer_button_okay) + click() + } + } + } + + step("Enter mock server address and click proceed") { + BaseUrlScreen { + progressBar { + isGone() + } + urlInput { + isVisible() + edit.replaceText(mockWebServer.url("/").toString()) + hasHint(R.string.fragment_authentication_input_hint_url) + } + proceedButton { + isVisible() + isEnabled() + hasText(R.string.fragment_base_url_save) + click() + } + } + } + + step("Check that empty list of recipes is shown") { + RecipesListScreen { + emptyListText { + isVisible() + hasText(R.string.fragment_recipes_list_no_recipes) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/MealientTestRunner.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/MealientTestRunner.kt new file mode 100644 index 0000000..4352422 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/MealientTestRunner.kt @@ -0,0 +1,15 @@ +package gq.kirmanak.mealient + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class MealientTestRunner : AndroidJUnitRunner() { + + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt new file mode 100644 index 0000000..51f8b14 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt @@ -0,0 +1,20 @@ +package gq.kirmanak.mealient + +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest + +val versionV1Response = MockResponse().setResponseCode(200).setBody( + """{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""" +) + +val notFoundResponse = MockResponse().setResponseCode(404).setBody("""{"detail":"Not found"}"""") + +fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) { + dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return block(request.path.orEmpty(), request) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt new file mode 100644 index 0000000..00f549f --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt @@ -0,0 +1,19 @@ +package gq.kirmanak.mealient.screen + +import com.kaspersky.kaspresso.screens.KScreen +import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.ui.baseurl.BaseURLFragment +import io.github.kakaocup.kakao.edit.KTextInputLayout +import io.github.kakaocup.kakao.progress.KProgressBar +import io.github.kakaocup.kakao.text.KButton + +object BaseUrlScreen : KScreen() { + override val layoutId = R.layout.fragment_base_url + override val viewClass = BaseURLFragment::class.java + + val urlInput = KTextInputLayout { withId(R.id.url_input_layout) } + + val proceedButton = KButton { withId(R.id.button) } + + val progressBar = KProgressBar { withId(R.id.progress)} +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt new file mode 100644 index 0000000..5dafb30 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt @@ -0,0 +1,16 @@ +package gq.kirmanak.mealient.screen + +import com.kaspersky.kaspresso.screens.KScreen +import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment +import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.kakao.text.KTextView + +object DisclaimerScreen : KScreen() { + override val layoutId = R.layout.fragment_disclaimer + override val viewClass = DisclaimerFragment::class.java + + val okayButton = KButton { withId(R.id.okay) } + + val disclaimerText = KTextView { withId(R.id.main_text) } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt new file mode 100644 index 0000000..6c2b171 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt @@ -0,0 +1,13 @@ +package gq.kirmanak.mealient.screen + +import com.kaspersky.kaspresso.screens.KScreen +import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.ui.recipes.RecipesListFragment +import io.github.kakaocup.kakao.text.KTextView + +object RecipesListScreen : KScreen() { + override val layoutId: Int = R.layout.fragment_recipes_list + override val viewClass: Class<*> = RecipesListFragment::class.java + + val emptyListText = KTextView { withId(R.id.empty_list_text) } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt index 6330c7a..42afc2b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -28,7 +28,8 @@ class BaseURLViewModel @Inject constructor( logger.v { "saveBaseUrl() called with: baseURL = $baseURL" } _uiState.value = OperationUiState.Progress() val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) } - val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL) + var url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL) + url = url.trimStart().trimEnd { it == '/' || it.isWhitespace() } viewModelScope.launch { checkBaseURL(url) } } diff --git a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt index cc8bb47..640a559 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt @@ -7,7 +7,7 @@ import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1 object AuthImplTestData { const val TEST_USERNAME = "TEST_USERNAME" const val TEST_PASSWORD = "TEST_PASSWORD" - const val TEST_BASE_URL = "https://example.com/" + const val TEST_BASE_URL = "https://example.com" const val TEST_TOKEN = "TEST_TOKEN" const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN" const val TEST_API_TOKEN = "TEST_API_TOKEN" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6f528f..cacae3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,12 @@ desugar = "1.2.2" kspPlugin = "1.7.20-1.0.7" # https://developer.android.com/jetpack/androidx/releases/sharetarget shareTarget = "1.2.0" +# https://github.com/KasperskyLab/Kaspresso/releases +kaspresso = "1.4.2" +# https://developer.android.com/jetpack/androidx/releases/test +androidXTest = "1.5.0" +# https://developer.android.com/jetpack/androidx/releases/test +androidXTestOrchestrator = "1.4.2" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -133,6 +139,11 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-test-junit = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidXTest" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTest" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidXTest" } +androidx-test-orchestrator = { group = "androidx.test", name = "orchestrator", version.ref = "androidXTestOrchestrator" } + jakewharton-retrofitSerialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerialization" } squareup-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } @@ -142,6 +153,7 @@ squareup-leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-an okhttp3-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" } okhttp3-okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } okhttp3-loggingInterceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" } +okhttp3-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } bumptech-glide-glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } bumptech-glide-okhttp3 = { group = "com.github.bumptech.glide", name = "okhttp3-integration", version.ref = "glide" } @@ -158,6 +170,8 @@ io-mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } chuckerteam-chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } +kaspersky-kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" } + [plugins] sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } rootcoverage = { id = "nl.neotech.plugin.rootcoverage", version.ref = "rootCoverage" } diff --git a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt index 50bc7b0..da5291f 100644 --- a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt +++ b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt @@ -22,7 +22,7 @@ open class BaseUnitTest { val instantExecutorRule = InstantTaskExecutorRule() @get:Rule(order = 1) - val timeoutRule: Timeout = Timeout.seconds(10) + val timeoutRule: Timeout = Timeout.seconds(20) protected val logger: Logger = FakeLogger()