diff --git a/.github/workflows/integrated-tests.yml b/.github/workflows/integrated-tests.yml new file mode 100644 index 00000000..dd82168e --- /dev/null +++ b/.github/workflows/integrated-tests.yml @@ -0,0 +1,92 @@ +name: Integrated Tests + +on: + pull_request: + branches: [ master ] + paths-ignore: + - 'fastlane/**' + - 'icons/**' + - 'images/**' + - 'scripts/**' + - 'whatsnew/**' + - '**.md' + - '**.MD' + +# If two events are triggered within a short time in the same PR, cancel the run of the oldest event +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + integrated-tests: + name: 'Run Integrated UI Tests' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'jetbrains' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: ๐Ÿ— Disable Firebase + run: | + bash ./scripts/enableDisableFirebase.sh false + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests on Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: default + arch: x86_64 + profile: Nexus 6 + emulator-boot-timeout: 600 + script: ./gradlew connectedFreeDebugAndroidTest --stacktrace + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: app/build/reports/androidTests/connected/ + retention-days: 7 + + - name: ๐Ÿ’ฌ Comment on PR with test results + uses: actions/github-script@v7 + if: always() && github.event_name == 'pull_request' + with: + script: | + const runId = context.runId; + const repo = context.repo; + const prNumber = context.issue.number; + const runUrl = `https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId}`; + + const comment = `## ๐Ÿงช Integrated Test Results + + The integrated UI tests have completed. + + **View full test results:** [Test Run #${runId}](${runUrl}) + + Test reports are available in the artifacts section of the workflow run.`; + + github.rest.issues.createComment({ + owner: repo.owner, + repo: repo.repo, + issue_number: prNumber, + body: comment + }); diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 499da4b9..fd724366 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,6 +22,13 @@ android { versionName = "1.0.18" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments += mapOf( + "clearPackageData" to "true", + ) + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" } productFlavors { @@ -115,6 +122,7 @@ dependencies { testImplementation(libs.ktor.client.mock) testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) + androidTestUtil(libs.orchestrator) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) diff --git a/app/src/androidTest/java/com/yogeshpaliyal/deepr/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/yogeshpaliyal/deepr/ExampleInstrumentedTest.kt index 2134d62b..28722db7 100644 --- a/app/src/androidTest/java/com/yogeshpaliyal/deepr/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/yogeshpaliyal/deepr/ExampleInstrumentedTest.kt @@ -17,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.yogeshpaliyal.deepr", appContext.packageName) + assertEquals("com.yogeshpaliyal.deepr.debug", appContext.packageName) } } diff --git a/app/src/androidTest/java/com/yogeshpaliyal/deepr/ui/DeeprIntegratedTest.kt b/app/src/androidTest/java/com/yogeshpaliyal/deepr/ui/DeeprIntegratedTest.kt new file mode 100644 index 00000000..22b4644c --- /dev/null +++ b/app/src/androidTest/java/com/yogeshpaliyal/deepr/ui/DeeprIntegratedTest.kt @@ -0,0 +1,310 @@ +package com.yogeshpaliyal.deepr.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.yogeshpaliyal.deepr.MainActivity +import com.yogeshpaliyal.deepr.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Integrated UI tests for Deepr app covering: + * - Add new link + * - Edit link + * - Delete link + * - Search functionality + * - Filter by tag + * - Add/remove favorites + */ +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) +class DeeprIntegratedTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun getString(resId: Int): String = composeTestRule.activity.getString(resId) + + @Test + fun testAddNewLink() { + // Wait for the app to load + composeTestRule.waitForIdle() + + // Click the FAB to add a new link + composeTestRule + .onNodeWithContentDescription(getString(R.string.add_link)) + .performClick() + + // Wait for the bottom sheet to appear + composeTestRule.waitForIdle() + + // Enter a deeplink + composeTestRule + .onNodeWithText(getString(R.string.enter_deeplink_command)) + .performTextInput("myapp://test") + + // Enter a name + composeTestRule + .onNodeWithText(getString(R.string.enter_link_name)) + .performTextInput("Test Link") + + // Click save button + composeTestRule + .onNodeWithText(getString(R.string.save)) + .performScrollTo() + .performClick() + + // Wait for the link to be saved + composeTestRule.waitForIdle() + + // Verify the link appears in the list + composeTestRule + .onNodeWithText("Test Link") + .assertIsDisplayed() + } + + @Test + fun testEditLink() { + // First add a link + testAddNewLink() + + // Wait for the list to update + composeTestRule.waitForIdle() + + // Find the link and swipe right to edit + composeTestRule + .onNodeWithText("Test Link") + .performTouchInput { + swipeRight() + } + + // Wait for the edit dialog to appear + composeTestRule.waitForIdle() + + // Verify we're in edit mode by checking for the Edit title + composeTestRule + .onNodeWithText(getString(R.string.edit)) + .assertIsDisplayed() + + // Clear and update the name + val nameField = composeTestRule.onNodeWithText(getString(R.string.enter_link_name)) + nameField.performTextClearance() + nameField.performTextInput("Updated Test Link") + + // Save changes + composeTestRule + .onNodeWithText(getString(R.string.save)) + .performScrollTo() + .performClick() + + // Wait for changes to be saved + composeTestRule.waitForIdle() + + // Verify the updated link appears + composeTestRule + .onNodeWithText("Updated Test Link") + .assertIsDisplayed() + } + + @Test + fun testDeleteLink() { + // First add a link + testAddNewLink() + + // Wait for the list to update + composeTestRule.waitForIdle() + + // Swipe left to delete + composeTestRule + .onNodeWithText("Test Link") + .performTouchInput { + swipeLeft() + } + + // Wait for the delete action + composeTestRule.waitForIdle() + + // Confirm deletion by clicking the Delete button in the dialog + composeTestRule + .onNodeWithText(getString(R.string.delete)) + .performClick() + + // Wait for deletion to complete + composeTestRule.waitForIdle() + + // Verify the link is no longer displayed + composeTestRule + .onNodeWithText("Test Link") + .assertDoesNotExist() + } + + @Test + fun testSearchFunctionality() { + // Add a couple of links + addLinkWithDetails("myapp://test1", "First Test Link") + addLinkWithDetails("myapp://test2", "Second Test Link") + + composeTestRule.waitForIdle() + + // Click on the search bar to expand it + composeTestRule + .onNodeWithContentDescription(getString(R.string.search)) + .performClick() + + composeTestRule.waitForIdle() + + // Enter search query + composeTestRule + .onNodeWithContentDescription(getString(R.string.search)) + .performTextInput("First") + + composeTestRule.waitForIdle() + + // Verify only the matching link is displayed + composeTestRule + .onNodeWithText("First Test Link") + .assertIsDisplayed() + } + + @Test + fun testAddRemoveFavorite() { + // Add a link + testAddNewLink() + + composeTestRule.waitForIdle() + + // Find and click the favorite button + composeTestRule + .onNodeWithContentDescription(getString(R.string.add_to_favourites)) + .performClick() + + composeTestRule.waitForIdle() + + // Verify the favorite icon changed + composeTestRule + .onNodeWithContentDescription(getString(R.string.remove_from_favourites)) + .assertIsDisplayed() + + // Click again to remove from favorites + composeTestRule + .onNodeWithContentDescription(getString(R.string.remove_from_favourites)) + .performClick() + + composeTestRule.waitForIdle() + + // Verify it's back to unfavorited state + composeTestRule + .onNodeWithContentDescription(getString(R.string.add_to_favourites)) + .assertIsDisplayed() + } + + @Test + fun testFilterByTag() { + // Add a link with a tag + addLinkWithTag("myapp://tagged", "Tagged Link", "TestTag") + + composeTestRule.waitForIdle() + + // Verify the link is displayed + composeTestRule + .onNodeWithText("Tagged Link") + .assertIsDisplayed() + + // Click on the tag to filter + composeTestRule + .onNodeWithText("TestTag") + .performClick() + + composeTestRule.waitForIdle() + + // Verify only the tagged link is displayed + composeTestRule + .onNodeWithText("Tagged Link") + .assertIsDisplayed() + } + + // Helper method to add a link with specific details + private fun addLinkWithDetails( + link: String, + name: String, + ) { + // Click the FAB + composeTestRule + .onNodeWithContentDescription(getString(R.string.add_link)) + .performClick() + + composeTestRule.waitForIdle() + + // Enter deeplink + composeTestRule + .onNodeWithText(getString(R.string.enter_deeplink_command)) + .performTextInput(link) + + // Enter name + composeTestRule + .onNodeWithText(getString(R.string.enter_link_name)) + .performTextInput(name) + + // Save + composeTestRule + .onNodeWithText(getString(R.string.save)) + .performScrollTo() + .performClick() + + composeTestRule.waitForIdle() + } + + // Helper method to add a link with a tag + private fun addLinkWithTag( + link: String, + name: String, + tag: String, + ) { + // Click the FAB + composeTestRule + .onNodeWithContentDescription(getString(R.string.add_link)) + .performClick() + + composeTestRule.waitForIdle() + + // Enter deeplink + composeTestRule + .onNodeWithText(getString(R.string.enter_deeplink_command)) + .performTextInput(link) + + // Enter name + composeTestRule + .onNodeWithText(getString(R.string.enter_link_name)) + .performTextInput(name) + + // Add tag + composeTestRule + .onNodeWithText(getString(R.string.new_tag)) + .performTextInput(tag) + + composeTestRule + .onNodeWithText(getString(R.string.create_tag)) + .performClick() + + composeTestRule.waitForIdle() + + // Save + composeTestRule + .onNodeWithText(getString(R.string.save)) + .performScrollTo() + .performClick() + + composeTestRule.waitForIdle() + } +} diff --git a/app/src/debug/res/xml/data_extraction_rules.xml b/app/src/debug/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9e78c630 --- /dev/null +++ b/app/src/debug/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/DeeprItem.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/DeeprItem.kt index 6c571a5e..20a14327 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/DeeprItem.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/DeeprItem.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -228,6 +229,7 @@ fun DeeprItem( ), modifier = Modifier + .testTag("DeeprItem") .fillMaxWidth() .combinedClickable( onClick = { onItemClick(MenuItem.Click(account)) }, diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df92558..25b13fd8 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,5 @@ See https://developer.android.com/about/versions/12/backup-restore --> - + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/release/res/xml/data_extraction_rules.xml similarity index 99% rename from app/src/main/res/xml/data_extraction_rules.xml rename to app/src/release/res/xml/data_extraction_rules.xml index 9ee9997b..2e4a8ff3 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/release/res/xml/data_extraction_rules.xml @@ -9,6 +9,7 @@ --> +