diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06df44e6d3e..2525bf81ad2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,7 +143,7 @@ jobs: uses: bitwarden/gh-actions/get-keyvault-secrets@main with: keyvault: gh-android - secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD" + secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD,BWS-ACCESS-TOKEN" - name: Retrieve secrets env: @@ -261,6 +261,47 @@ jobs: keyAlias:bitwarden \ keyPassword:${{ env.PLAY-KEYSTORE-PASSWORD }} + - name: Retrieve test data + if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} + uses: bitwarden/sm-action@14f92f1d294ae3c2b6a3845d389cd2c318b0dfd8 # v2.2.0 + with: + access_token: ${{ steps.get-kv-secrets.outputs.BWS-ACCESS-TOKEN }} + secrets: | + 63e93f73-5118-4a62-9db8-b3160176aa8a > TEST_ACCOUNT_CREDS + + - name: Configure .json test data file + run: printf %s '${{ env.TEST_ACCOUNT_CREDS }}' > app/src/androidTest/assets/TestData.json + + - name: Build test APK (espresso) + if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} + env: + _TEST_APK_PATH: app/build/outputs/apk/androidTest/standard/release/com.x8bit.bitwarden-standard-release-androidTest.apk + _TEST_APK_SIGNED_PATH: app/build/outputs/apk/androidTest/standard/release/com.x8bit.bitwarden-test.apk + run: | + bundle exec fastlane assembleTestApk \ + storeFile:app_play-keystore.jks \ + storePassword:${{ env.PLAY-KEYSTORE-PASSWORD }} \ + keyAlias:bitwarden \ + keyPassword:${{ env.PLAY-KEYSTORE-PASSWORD }} + mv $_TEST_APK_PATH $_TEST_APK_SIGNED_PATH + + # TODO: test if bundle exec fastlane assembleTestApk works and replace this step + # - name: Sign and rename test APK + # if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} + # env: + # _TEST_APK_PATH: app/build/outputs/apk/androidTest/standard/release/com.x8bit.bitwarden-standard-release-androidTest.apk + # _TEST_APK_SIGNED_PATH: app/build/outputs/apk/androidTest/standard/release/com.x8bit.bitwarden-test.apk + # _PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }} + # _PLAY_KEYSTORE_ALIAS: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-ALIAS }} + # run: | + # $ANDROID_SDK_ROOT/build-tools/34.0.0/apksigner sign \ + # --ks keystores/app_play-keystore.jks \ + # --ks-key-alias bitwarden \ + # --ks-pass pass:$_PLAY_KEYSTORE_PASSWORD \ + # --key-pass pass:$_PLAY_KEYSTORE_PASSWORD \ + # $_TEST_APK_PATH + # mv $_TEST_APK_PATH $_TEST_APK_SIGNED_PATH + - name: Generate beta Play Store APK if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} env: @@ -302,6 +343,14 @@ jobs: path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk if-no-files-found: error + - name: Upload test .apk artifact + if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: com.x8bit.bitwarden-test.apk + path: app/build/outputs/apk/androidTest/standard/release/com.x8bit.bitwarden-test.apk + if-no-files-found: error + - name: Upload beta .apk artifact if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -422,6 +471,19 @@ jobs: bundle exec fastlane publishProdToPlayStore bundle exec fastlane publishBetaToPlayStore + test-device: + name: Test device + needs: publish_playstore + uses: bitwarden/android/.github/workflows/test-device.yml@QA-1126b/adding-native-sanity-test #TODO replace branch with main before merging + with: + apk_filename: com.x8bit.bitwarden.apk + test_apk_filename: com.x8bit.bitwarden-test.apk + permissions: + actions: read + checks: write + contents: read + id-token: write + publish_fdroid: name: Publish F-Droid artifacts needs: diff --git a/.github/workflows/test-device.yml b/.github/workflows/test-device.yml index f81f00ff407..fcae2698981 100644 --- a/.github/workflows/test-device.yml +++ b/.github/workflows/test-device.yml @@ -1,16 +1,90 @@ name: Test Device on: - workflow_dispatch: + workflow_call: + inputs: + apk_filename: + type: string + description: "Filename of the APK file to test" + default: com.x8bit.bitwarden.apk + test_apk_filename: + type: string + description: "Filename of the test APK file to test" + default: com.x8bit.bitwarden-test.apk +env: + _APK_PATH: artifacts/${{ inputs.apk_filename }} + _TEST_APK_PATH: artifacts/${{ inputs.test_apk_filename }} + +# TODO confirm if these permissions are needed permissions: contents: read + actions: read + checks: write + id-token: write jobs: - test: - name: Test Device + test-device: + name: Check main build against real devices runs-on: ubuntu-24.04 steps: - - name: Placeholder step - run: echo "Placeholder workflow step" + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get E2E secrets from Azure + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-android + secrets: "SAUCE-LABS-USERNAME,SAUCE-LABS-ACCESS-KEY" + id: get-kv-secrets + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Download release APK artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.apk_filename }} + path: artifacts + + - name: Download test APK artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.test_apk_filename }} + path: artifacts + + - name: Install saucectl + run: | + npm i -g saucectl + + - name: Upload APK to SauceLabs storage + run: | + saucectl storage upload $_APK_PATH + env: + SAUCE_USERNAME: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-USERNAME }} + SAUCE_ACCESS_KEY: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-ACCESS-KEY }} + + - name: Upload test APK to SauceLabs storage + env: + SAUCE_USERNAME: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-USERNAME }} + SAUCE_ACCESS_KEY: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-ACCESS-KEY }} + run: | + saucectl storage upload $_TEST_APK_PATH + + - name: Run tests on SauceLabs + run: saucectl run --config .sauce/config.yml + env: + SAUCE_USERNAME: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-USERNAME }} + SAUCE_ACCESS_KEY: ${{ steps.get-kv-secrets.outputs.SAUCE-LABS-ACCESS-KEY }} + + - name: Upload SauceLabs test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: saucectl-report + path: saucectl-report.xml diff --git a/.sauce/config.yml b/.sauce/config.yml new file mode 100644 index 00000000000..ac04c40d464 --- /dev/null +++ b/.sauce/config.yml @@ -0,0 +1,32 @@ +apiVersion: v1alpha +kind: espresso +defaults: + timeout: 10m +sauce: + region: us-west-1 + # Controls how many suites are executed at the same time (sauce test env only). + concurrency: 1 + retries: 1 + visibility: team + metadata: + tags: + - Android + - sanity-e2e + build: Sanity check on Real devices +reporters: + junit: + enabled: true + filename: saucectl-report.xml +espresso: + app: storage:filename=com.x8bit.bitwarden.apk + testApp: storage:filename=com.x8bit.bitwarden-standard-release-androidTest.apk +suites: + - name: "Android - Sanity" + devices: + - name: "Google.*" + platformVersion: "^1[3456].*" + options: + deviceType: PHONE + testOptions: + package: e2e.tests + resigningEnabled: false diff --git a/README.md b/README.md index 1014da08a8a..002caa36ec8 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,27 @@ The following is a list of additional third-party dependencies used as part of t - Purpose: A small testing library for kotlinx.coroutine's Flow. - License: Apache 2.0 +- **AndroidX Espresso Core** + - https://developer.android.com/jetpack/androidx/releases/espresso + - Purpose: UI testing framework for Android. + - License: Apache 2.0 + +- **AndroidX JUnit KTX** + - https://developer.android.com/jetpack/androidx/releases/junit + - Purpose: Kotlin extensions for JUnit-based Android tests. + - License: Apache 2.0 + +- **AndroidX UIAutomator** + - https://developer.android.com/training/testing/other-components/ui-automator + - Purpose: UI testing across multiple apps. + - License: Apache 2.0 + +- **AndroidX Compose UI Test JUnit4 (Android)** + - https://developer.android.com/jetpack/androidx/releases/compose-ui + - Purpose: Compose UI testing for Android using JUnit4. + - License: Apache 2.0 + + ### CI/CD Dependencies The following is a list of additional third-party dependencies used as part of the CI/CD workflows. These are not present in the final packaged application. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fa74e8db02..f903126e2df 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,9 @@ android { namespace = "com.x8bit.bitwarden" compileSdk = libs.versions.compileSdk.get().toInt() + // Required for SauceLabs integration + testBuildType = "release" + room { schemaDirectory("$projectDir/schemas") } @@ -253,6 +256,10 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.uiautomator) + implementation(libs.androidx.espresso.core) + implementation(libs.androidx.junit.ktx) + implementation(libs.androidx.ui.test.junit4.android) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.runtime) @@ -288,7 +295,6 @@ dependencies { testImplementation(testFixtures(project(":network"))) testImplementation(testFixtures(project(":ui"))) - testImplementation(libs.androidx.compose.ui.test) testImplementation(libs.google.hilt.android.testing) testImplementation(platform(libs.junit.bom)) testRuntimeOnly(libs.junit.platform.launcher) @@ -298,6 +304,11 @@ dependencies { testImplementation(libs.mockk.mockk) testImplementation(libs.robolectric.robolectric) testImplementation(libs.square.turbine) + androidTestImplementation(libs.androidx.compose.ui.test) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit.ktx) + androidTestImplementation(libs.androidx.ui.test.junit4.android) + androidTestImplementation(libs.androidx.uiautomator) } tasks { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d4963ef3c0f..7e71c02a196 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -121,3 +121,23 @@ -dontwarn com.google.errorprone.annotations.CheckReturnValue -dontwarn com.google.errorprone.annotations.Immutable -dontwarn com.google.errorprone.annotations.RestrictedApi + +################################################################################ +# AndroidX Test Runner +################################################################################ + +# Keep the test runner classes +-keep class androidx.test.runner.** { *; } +-keep class androidx.test.internal.runner.** { *; } +-keep class androidx.test.ext.junit.** { *; } +-keep class androidx.test.ext.** { *; } +-keep class androidx.test.** { *; } + +# Keep Compose test classes +-keep class androidx.compose.ui.test.** { *; } +-keep class androidx.compose.ui.test.junit4.** { *; } + +# Keep Kotlin standard library classes +-keep class kotlin.** { *; } +-keep class kotlinx.** { *; } +-keep class kotlin.io.** { *; } diff --git a/app/src/androidTest/assets/TestData.json b/app/src/androidTest/assets/TestData.json new file mode 100644 index 00000000000..cafde484b9e --- /dev/null +++ b/app/src/androidTest/assets/TestData.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "_", + "email": "_", + "password": "_" +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestData.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestData.kt new file mode 100644 index 00000000000..173044e9fb5 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestData.kt @@ -0,0 +1,10 @@ +package com.x8bit.bitwarden.data + +import kotlinx.serialization.Serializable + +@Serializable +data class TestData( + val baseUrl: String, + val email: String, + val password: String, +) diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestDataReader.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestDataReader.kt new file mode 100644 index 00000000000..3cbd257cb4e --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/data/TestDataReader.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data + +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.serialization.json.Json +import java.nio.charset.StandardCharsets + +object TestDataReader { + fun getTestData(fileName: String): TestData { + val assets = InstrumentationRegistry.getInstrumentation().context.assets + val jsonString = assets + .open(fileName) + .use { inputStream -> + inputStream.bufferedReader(StandardCharsets.UTF_8) + .readText() + } + return Json.decodeFromString(jsonString) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/Page.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/Page.kt new file mode 100644 index 00000000000..edb6b499ebb --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/Page.kt @@ -0,0 +1,122 @@ +package com.x8bit.bitwarden.e2e.pageObjects + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick + +/** + * Base class for all page objects in the Bitwarden app. + * Provides a shared ComposeTestRule instance for UI testing. + */ +abstract class Page(protected val composeTestRule: ComposeTestRule) { + companion object { + const val TIMEOUT_MILLIS = 30000L + } + + /** + * Waits for an element with the specified test tag to be present. + * @param testTag The test tag of the element to wait for + * @return SemanticsNodeInteraction for the found element + * @throws AssertionError if the element is not found within the timeout period + */ + protected fun getElement(testTag: String): SemanticsNodeInteraction { + waitForIdle() + waitUntil() { + try { + composeTestRule.onNodeWithTag(testTag).assertExists() + true + } catch (e: AssertionError) { + false + } + } + return composeTestRule.onNodeWithTag(testTag) + } + + protected fun getElementByText(text: String): SemanticsNodeInteraction { + waitForIdle() + waitUntil() { + try { + composeTestRule.onNodeWithText(text).assertExists() + true + } catch (e: AssertionError) { + false + } + } + return composeTestRule.onNodeWithText(text) + } + + /** + * Waits for the app to be idle before proceeding with any UI interactions. + * This helps prevent flaky tests by ensuring the UI is stable. + */ + protected fun waitForIdle() { + composeTestRule.waitForIdle() + } + + /** + * Waits for a specific condition to be true before proceeding. + * @param timeoutMillis Maximum time to wait in milliseconds + * @param condition The condition to wait for + */ + protected fun waitUntil( + timeoutMillis: Long = TIMEOUT_MILLIS, + condition: () -> Boolean, + ) { + composeTestRule.waitUntil(timeoutMillis) { condition() } + } + + /** + * Performs a click action on a node with the given test tag. + * @param testTag The test tag of the node to click + */ + protected fun clickOnNodeWithTag(testTag: String) { + getElement(testTag).performClick() + } + + /** + * Verifies that a node with the given test tag is displayed. + * @param testTag The test tag of the node to verify + */ + protected fun verifyNodeWithTagIsDisplayed(testTag: String) { + getElement(testTag).assertIsDisplayed() + } + + /** + * Verifies that a node with the given test tag is not displayed. + * @param testTag The test tag of the node to verify + */ + protected fun verifyNodeWithTagIsNotDisplayed(testTag: String) { + composeTestRule.onNodeWithTag(testTag).assertDoesNotExist() + } + + /** + * Verifies that a node with the given test tag is enabled. + * @param testTag The test tag of the node to verify + */ + protected fun verifyNodeWithTagIsEnabled(testTag: String) { + getElement(testTag).assertIsEnabled() + } + + /** + * Verifies that a node with the given test tag is disabled. + * @param testTag The test tag of the node to verify + */ + protected fun verifyNodeWithTagIsDisabled(testTag: String) { + getElement(testTag).assertIsNotEnabled() + } + + /** + * Verifies that a node with the given test tag has the expected text. + * @param testTag The test tag of the node to verify + * @param expectedText The expected text content + */ + protected fun verifyNodeWithTagHasText(testTag: String, expectedText: String) { + getElement(testTag).assertTextEquals(expectedText) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/EnvironmentSettingsPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/EnvironmentSettingsPage.kt new file mode 100644 index 00000000000..7f10f9bcc31 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/EnvironmentSettingsPage.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.e2e.pageObjects.login + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.e2e.pageObjects.Page + +class EnvironmentSettingsPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val serverUrlField by lazy { getElement("ServerUrlEntry") } + private val saveButton by lazy { getElement("SaveButton") } + + fun setupEnvironment(url: String): LoginPage { + serverUrlField + .performClick() + .performTextInput(url) + saveButton.performClick() + return LoginPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/LoginPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/LoginPage.kt new file mode 100644 index 00000000000..c1fdc7d666f --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/LoginPage.kt @@ -0,0 +1,60 @@ +package com.x8bit.bitwarden.e2e.pageObjects.login + +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.x8bit.bitwarden.e2e.pageObjects.Page +import com.x8bit.bitwarden.e2e.pageObjects.vault.VaultPage + +/** + * Page Object representing the Login screen of the Bitwarden app. + * This class encapsulates all the UI elements and actions available on the login screen. + */ +class LoginPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val emailField by lazy { getElement("EmailAddressEntry") } + private val masterPasswordField by lazy { getElement("MasterPasswordEntry") } + private val continueButton by lazy { getElement("ContinueButton") } + private val loginWithMasterPasswordButton by lazy { + getElement("LogInWithMasterPasswordButton") + } + private val regionSelectorButton by lazy { getElement("RegionSelectorDropdown") } + private val openSettingsButton by lazy { getElement("AppSettingsButton") } + private val otherSettingsButton by lazy { getElement("OtherSettingsButton") } + private val allowScreenCaptureToggle by lazy { getElement("AllowScreenCaptureSwitch") } + private val goBackButton by lazy { getElement("CloseButton") } + + /** + * Enters the master password in the password field + * @param password The master password to enter + * @return This LoginPage instance for method chaining + */ + fun performLogin(email: String, password: String): VaultPage { + emailField + .performClick() + .performTextInput(email) + continueButton + .performClick() + masterPasswordField + .performClick() + .performTextInput(password) + loginWithMasterPasswordButton.performClick() + return VaultPage(composeTestRule) + } + + fun openEnvironmentSettings(): EnvironmentSettingsPage { + regionSelectorButton.performClick() + getElementByText("Self-hosted") + .performClick() + return EnvironmentSettingsPage(composeTestRule) + } + + fun turnOnScreenRecording(): LoginPage { + openSettingsButton.performClick() + otherSettingsButton.performClick() + allowScreenCaptureToggle.performClick() + goBackButton.performClick() + goBackButton.performClick() + return this + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/MainPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/MainPage.kt new file mode 100644 index 00000000000..3a788ad8a25 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/login/MainPage.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.e2e.pageObjects.login + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.e2e.pageObjects.Page + +class MainPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val loginButton by lazy { getElement("ChooseLoginButton") } + private val createAccountButton by lazy { getElement("ChooseAccountCreationButton") } + + fun startLogin(): LoginPage { + loginButton.performClick() + return LoginPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/SettingsPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/SettingsPage.kt new file mode 100644 index 00000000000..f7243d3456b --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/SettingsPage.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.e2e.pageObjects.settings + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.e2e.pageObjects.Page +import com.x8bit.bitwarden.e2e.pageObjects.settings.accountSecurity.AccountSecurityPage + +class SettingsPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val accountSecurityButton by lazy { getElement("AccountSecuritySettingsButton") } + + /** + * Navigates to the Account Security settings + * @return This SettingsPage instance for method chaining + */ + fun navigateToAccountSecurity(): AccountSecurityPage { + accountSecurityButton.performClick() + return AccountSecurityPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/accountSecurity/AccountSecurityPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/accountSecurity/AccountSecurityPage.kt new file mode 100644 index 00000000000..751d13787a0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/settings/accountSecurity/AccountSecurityPage.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.e2e.pageObjects.settings.accountSecurity + +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.x8bit.bitwarden.e2e.pageObjects.Page +import com.x8bit.bitwarden.e2e.pageObjects.vault.UnlockVaultPage + +/** + * Page Object representing the Account Security screen of the Bitwarden app. + * This class encapsulates all the UI elements and actions available on the account security screen. + */ +class AccountSecurityPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val lockNowLabel by lazy { getElement("LockNowLabel") } + + /** + * Locks the vault + * @return This AccountSecurityPage instance for method chaining + */ + fun lockVault(): UnlockVaultPage { + lockNowLabel.performScrollTo().performClick() + lockNowLabel.assertIsNotDisplayed() + return UnlockVaultPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/UnlockVaultPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/UnlockVaultPage.kt new file mode 100644 index 00000000000..d0098ffecc8 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/UnlockVaultPage.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.e2e.pageObjects.vault + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.e2e.pageObjects.Page + +class UnlockVaultPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val passwordEntryTag by lazy { getElement("MasterPasswordEntry") } + private val unlockVaultButtonTag by lazy { getElement("UnlockVaultButton") } + + fun enterPassword(password: String): UnlockVaultPage { + passwordEntryTag.performTextInput(password) + return this + } + + fun performUnlockVault(password: String): VaultPage { + unlockVaultButtonTag.assertIsDisplayed() + passwordEntryTag.performClick().performTextInput(password) + unlockVaultButtonTag.performClick() + return VaultPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/VaultPage.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/VaultPage.kt new file mode 100644 index 00000000000..b8da915b629 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/pageObjects/vault/VaultPage.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.e2e.pageObjects.vault + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.e2e.pageObjects.Page +import com.x8bit.bitwarden.e2e.pageObjects.settings.SettingsPage + +class VaultPage(composeTestRule: ComposeTestRule) : Page(composeTestRule) { + + private val settingsMenuButton by lazy { getElement("SettingsTab") } + private val addItemButton by lazy { getElement("AddItemButton") } + + fun assertVaultIsUnlocked() { + addItemButton.assertIsDisplayed() + } + + fun navigateToSettingsPage(): SettingsPage { + settingsMenuButton.performClick() + return SettingsPage(composeTestRule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/BaseE2eTest.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/BaseE2eTest.kt new file mode 100644 index 00000000000..9d8c949efc7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/BaseE2eTest.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.e2e.tests + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.x8bit.bitwarden.MainActivity +import com.x8bit.bitwarden.data.TestDataReader +import org.junit.Rule + +open class BaseE2eTest { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + // Workaround to find Compose UI elements on Espresso tests + @get:Rule + val composeTestRule: ComposeTestRule = createEmptyComposeRule() + + val testData = TestDataReader.getTestData("TestData.json") +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/RealDeviceE2eTests.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/RealDeviceE2eTests.kt new file mode 100644 index 00000000000..8ea32cf45d5 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/tests/RealDeviceE2eTests.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.e2e.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.x8bit.bitwarden.e2e.pageObjects.login.MainPage +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealDeviceE2eTests : BaseE2eTest() { + + @Test + fun testVaultLockUnlockFlow() { + var vault = MainPage(composeTestRule) + .startLogin() + .turnOnScreenRecording() + .openEnvironmentSettings() + .setupEnvironment(testData.baseUrl) + .performLogin(testData.email, testData.password) + vault.assertVaultIsUnlocked() + vault.navigateToSettingsPage() + .navigateToAccountSecurity() + .lockVault() + .performUnlockVault(testData.password) + .assertVaultIsUnlocked() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index 71411f5cafe..83f4e60c76d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -324,7 +324,8 @@ private fun LandingScreenContent( icon = rememberVectorPainter(id = BitwardenDrawable.ic_cog), modifier = Modifier .standardHorizontalMargin() - .fillMaxWidth(), + .fillMaxWidth() + .testTag("AppSettingsButton"), ) Spacer(modifier = Modifier.height(height = 12.dp)) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 6447dd43878..8fc96fef2be 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -69,6 +69,18 @@ platform :android do ) end + desc "Assemble test APK" + lane :assembleTestApk do |options| + buildAndSignBitwarden( + taskName: "assembleAndroidTest", + buildType: "Release", + storeFile: options[:storeFile], + storePassword: options[:storePassword], + keyAlias: options[:keyAlias], + keyPassword: options[:keyPassword], + ) + end + desc "Assemble Play Store release APK" lane :assemblePlayStoreBetaApk do |options| buildAndSignBitwarden( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96cd08e3131..54da721a0f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ androidxWork = "2.10.5" bitwardenSdk = "1.0.0-3175-c9758478" crashlytics = "3.0.6" detekt = "1.23.8" +espressoCore = "3.6.1" firebaseBom = "34.2.0" glide = "1.0.0-beta01" googleGuava = "33.4.8-jre" @@ -42,6 +43,7 @@ googleServices = "4.4.3" googleReview = "2.0.2" hilt = "2.57.1" junit5 = "5.13.4" +junitKtx = "1.2.1" jvmTarget = "21" # kotlin and ksp **must** use compatible versions, do not update either without the other. kotlin = "2.2.20" @@ -58,6 +60,7 @@ sonarqube = "6.2.0.5505" testng = "7.11.0" timber = "5.0.1" turbine = "1.2.1" +uiautomator = "2.3.0" zxing = "3.5.3" [libraries] @@ -86,7 +89,9 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" } androidx-credentials-providerevents = { module = "androidx.credentials.providerevents:providerevents", version.ref = "androidxCredentialsProviderEvents" } androidx-credentials-providerevents-play-services = { module = "androidx.credentials.providerevents:providerevents-play-services", version.ref = "androidxCredentialsProviderEvents" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } @@ -96,6 +101,8 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidxSecurityCrypto" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxSplash" } +androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" } bitwarden-sdk = { module = "com.bitwarden:sdk-android", version.ref = "bitwardenSdk" } bumptech-glide = { module = "com.github.bumptech.glide:compose", version.ref = "glide" }