From 74f0e670e1054f6c7ff2fef4af3e1b4d7b6bb712 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 16 Jan 2026 22:38:42 +0100 Subject: [PATCH] Add Android app UI test scaffolding (#1404) --- apps/mobile-app/android/app/build.gradle | 13 +- .../net/aliasvault/app/AliasVaultUITests.kt | 724 ++++++++++++++++++ .../net/aliasvault/app/TestConfiguration.kt | 49 ++ .../aliasvault/app/TestUserRegistration.kt | 523 +++++++++++++ .../java/net/aliasvault/app/UITestHelpers.kt | 399 ++++++++++ apps/mobile-app/package-lock.json | 112 --- apps/mobile-app/package.json | 1 - 7 files changed, 1707 insertions(+), 114 deletions(-) create mode 100644 apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/AliasVaultUITests.kt create mode 100644 apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestConfiguration.kt create mode 100644 apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestUserRegistration.kt create mode 100644 apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/UITestHelpers.kt diff --git a/apps/mobile-app/android/app/build.gradle b/apps/mobile-app/android/app/build.gradle index 91e1ab13d..f6b9d1dcd 100644 --- a/apps/mobile-app/android/app/build.gradle +++ b/apps/mobile-app/android/app/build.gradle @@ -95,6 +95,9 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 2600100 versionName "0.26.0-alpha" + + // Instrumented test configuration + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { debug { @@ -214,7 +217,7 @@ dependencies { } } - // Test dependencies + // Test dependencies (unit tests) testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.0.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0' @@ -227,6 +230,14 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' testImplementation 'org.junit.vintage:junit-vintage-engine:5.9.2' + // Instrumented test dependencies (androidTest) + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; diff --git a/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/AliasVaultUITests.kt b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/AliasVaultUITests.kt new file mode 100644 index 000000000..9d85521be --- /dev/null +++ b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/AliasVaultUITests.kt @@ -0,0 +1,724 @@ +package net.aliasvault.app + +import android.content.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import kotlinx.coroutines.runBlocking +import net.aliasvault.app.UITestHelpers.assertTestIdExists +import net.aliasvault.app.UITestHelpers.existsByTestId +import net.aliasvault.app.UITestHelpers.findByTestId +import net.aliasvault.app.UITestHelpers.findByText +import net.aliasvault.app.UITestHelpers.hideKeyboard +import net.aliasvault.app.UITestHelpers.longSleep +import net.aliasvault.app.UITestHelpers.scrollToTestId +import net.aliasvault.app.UITestHelpers.scrollToText +import net.aliasvault.app.UITestHelpers.tapTestId +import net.aliasvault.app.UITestHelpers.typeIntoTestId +import net.aliasvault.app.UITestHelpers.waitForTestId +import net.aliasvault.app.UITestHelpers.waitForText +import net.aliasvault.app.UITestHelpers.waitForTextContains +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters + +/** + * E2E UI Tests for AliasVault Android app. + * + * These tests use dynamically created test users via the API, so no pre-configured + * credentials are needed. Each test creates its own isolated test user to ensure + * tests can run independently (in isolation or in sequence) with a known state. + * + * Prerequisites: + * - Local API server running at the URL specified in TestConfiguration.apiUrl + * - Android Emulator with the app installed + * + * Note: Tests use UI Automator for interacting with React Native views via accessibility + * labels (testID in React Native maps to contentDescription in Android). + */ +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class AliasVaultUITests { + private lateinit var device: UiDevice + private val packageName = "net.aliasvault.app" + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // Wake up device if sleeping + device.wakeUp() + } + + @After + fun tearDown() { + // Take screenshot on failure would go here if needed + } + + // region Test Setup + + /** + * Creates a new test user for this test. + * Each test gets its own isolated user to ensure tests can run independently. + */ + private fun createTestUser(): TestUser = runBlocking { + // Check if API is available + val apiAvailable = TestUserRegistration.isApiAvailable() + assumeTrue("API not available at ${TestConfiguration.apiUrl}. Start the local server first.", apiAvailable) + + // Create a new test user for this specific test + val user = TestUserRegistration.createTestUser() + println("[Setup] Created test user: ${user.username}") + user + } + + /** + * Launch the app fresh. + * Note: For UI tests, the app uses a pre-bundled JS bundle (no Metro needed). + */ + private fun launchApp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = context.packageManager.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + + // Wait for app to start and JS bundle to load + Thread.sleep(5000) + + // Wait for one of the expected screens (check all simultaneously with polling) + val startTime = System.currentTimeMillis() + val timeout = TestConfiguration.EXTENDED_TIMEOUT_MS + while (System.currentTimeMillis() - startTime < timeout) { + if (device.findByTestId("login-screen") != null || + device.findByTestId("unlock-screen") != null || + device.findByTestId("items-screen") != null + ) { + return + } + Thread.sleep(200) + } + } + + /** + * Open a deep link in the app. + */ + private fun openDeepLink(url: String) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setPackage(packageName) + } + context.startActivity(intent) + longSleep() + } + + /** + * Pull to refresh gesture. + */ + private fun pullToRefresh() { + device.swipe( + device.displayWidth / 2, + device.displayHeight / 4, + device.displayWidth / 2, + device.displayHeight * 3 / 4, + 10, + ) + longSleep() + } + + // endregion + + // region Test 01: Create New Item + + /** + * Verifies item creation flow: opens add form, fills in details, saves, and verifies + * the item appears in the list. Creates its own isolated test user. + */ + @Test + fun test01CreateItem() { + val testUser = createTestUser() + val uniqueName = TestConfiguration.generateUniqueName("E2E Test") + println("[Test01] Creating item with name: $uniqueName") + + launchApp() + loginWithTestUser(testUser) + + // Verify items screen is displayed + println("[Test01] Verifying items screen is displayed") + device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + + // Create item + val itemParams = CreateItemParams( + name = uniqueName, + serviceUrl = "https://example.com", + email = "e2e-test@example.com", + username = "e2euser", + ) + + assertTrue("Should create item", createItem(itemParams)) + + // Verify item exists in list + assertTrue("Should find item in list", verifyItemExistsInList(uniqueName)) + + // Open and verify item details + assertTrue("Should verify item details", openAndVerifyItem(uniqueName, "e2e-test@example.com")) + + println("[Test01] Item creation and verification successful") + } + + // endregion + + // region Test 02: Offline Mode and Sync + + /** + * Verifies offline mode and sync recovery: + * 1. Goes offline by setting API URL to invalid (simulates network failure) + * 2. Creates a credential while offline (stored locally) + * 3. Goes back online and triggers sync + * 4. Verifies the credential persists after sync + */ + @Test + fun test02OfflineModeAndSync() { + val testUser = createTestUser() + val originalApiUrl = TestConfiguration.apiUrl + val invalidApiUrl = "http://offline.invalid.localhost:9999" + val uniqueName = TestConfiguration.generateUniqueName("Offline Test") + println("[Test02] Creating offline item with name: $uniqueName") + + launchApp() + loginWithTestUser(testUser) + + // Step 1: Verify online state + println("[Test02] Step 1: Verify online state") + device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + + // Step 2: Enable offline mode via deep link + println("[Test02] Step 2: Enable offline mode via deep link") + val encodedInvalidUrl = Uri.encode(invalidApiUrl) + println("[Test02] Setting API URL to invalid: $invalidApiUrl") + openDeepLink("aliasvault://open/__debug__/set-api-url/$encodedInvalidUrl") + + unlockVaultIfNeeded(testUser) + device.assertTestIdExists("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) + + // Trigger sync to detect offline state + pullToRefresh() + Thread.sleep(3000) + + device.assertTestIdExists("sync-indicator-offline", TestConfiguration.DEFAULT_TIMEOUT_MS) + println("[Test02] Offline mode enabled successfully") + + // Step 3: Create item while offline + println("[Test02] Step 3: Create item while offline") + val offlineItemParams = CreateItemParams( + name = uniqueName, + serviceUrl = "https://offline-test.example.com", + email = "offline-test@example.com", + ) + + assertTrue("Should create item while offline", createItem(offlineItemParams)) + assertTrue("Should find item in list", verifyItemExistsInList(uniqueName)) + + println("[Test02] Item created while offline and appears in list") + + // Step 4: Go back online and sync + println("[Test02] Step 4: Go back online and sync") + val encodedValidUrl = Uri.encode(originalApiUrl) + println("[Test02] Restoring API URL to: $originalApiUrl") + openDeepLink("aliasvault://open/__debug__/set-api-url/$encodedValidUrl") + + unlockVaultIfNeeded(testUser) + device.assertTestIdExists("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) + + Thread.sleep(2000) + pullToRefresh() + Thread.sleep(5000) + + // Verify offline indicator is gone + assertTrue( + "Offline indicator should be gone after sync", + !device.existsByTestId("sync-indicator-offline"), + ) + + println("[Test02] Back online and synced successfully") + + // Step 5: Verify item persists after sync + println("[Test02] Step 5: Verify item persists after sync") + assertTrue( + "Should verify item after sync", + openAndVerifyItem(uniqueName, "offline-test@example.com"), + ) + + println("[Test02] Offline item verified after sync - test passed") + } + + // endregion + + // region Test 03: RPO Recovery + + /** + * Verifies RPO (Recovery Point Objective) recovery scenario: + * When the client detects that its local server revision is higher than the actual server revision + * (simulating server data loss/rollback), it should upload its vault to recover the server state. + */ + @Test + fun test03RPORecovery() = runBlocking { + val testUser = createTestUser() + val uniqueName = TestConfiguration.generateUniqueName("RPO Test") + println("[Test03] Testing RPO recovery with item: $uniqueName") + + launchApp() + loginWithTestUser(testUser) + + // Step 1: Verify initial state and get server revision + println("[Test03] Step 1: Verify initial state") + device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + + val initialRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val initialRevision = initialRevisions.second + println("[Test03] Initial server revision: $initialRevision") + + // Step 2: Create credential while online + println("[Test03] Step 2: Create credential while online") + val rpoItemParams = CreateItemParams( + name = uniqueName, + serviceUrl = "https://rpo-test.example.com", + email = "rpo-test@example.com", + ) + + assertTrue("Should create item", createItem(rpoItemParams)) + Thread.sleep(1000) // Wait for sync + + println("[Test03] Credential created and synced to server") + + val afterCreateRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val revisionAfterCreate = afterCreateRevisions.second + println("[Test03] Server revision after create: $revisionAfterCreate") + + assertTrue( + "Server revision should increase after creating credential", + revisionAfterCreate > initialRevision, + ) + + // Step 3: Simulate server data loss + println("[Test03] Step 3: Simulate server data loss") + val deletedCount = TestUserRegistration.deleteVaultRevisionsByUsername(testUser.username, 1) + println("[Test03] Deleted $deletedCount vault revision(s) from server to simulate data loss") + + val afterDeleteRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val revisionAfterDelete = afterDeleteRevisions.second + println("[Test03] Server revision after delete: $revisionAfterDelete") + + assertTrue( + "Server revision should decrease after deleting vault revision", + revisionAfterDelete < revisionAfterCreate, + ) + + // Step 4: Trigger sync for RPO recovery + println("[Test03] Step 4: Trigger sync for RPO recovery") + println("[Test03] Triggering sync - client should detect RPO scenario and upload vault") + pullToRefresh() + Thread.sleep(5000) + + // Step 5: Verify credential persists after RPO recovery + println("[Test03] Step 5: Verify credential persists after RPO recovery") + assertTrue( + "Credential should still exist after RPO recovery", + openAndVerifyItem(uniqueName, "rpo-test@example.com"), + ) + + // Verify server revision restored + val finalRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val finalRevision = finalRevisions.second + println("[Test03] Final server revision: $finalRevision") + + assertTrue( + "Server revision should be restored after RPO recovery", + finalRevision >= revisionAfterCreate, + ) + + println( + "[Test03] SUCCESS - Revision flow: $initialRevision → $revisionAfterCreate " + + "(create) → $revisionAfterDelete (rollback) → $finalRevision (recovered)", + ) + } + + // endregion + + // region Test 04: Forced Logout Recovery + + /** + * Verifies forced logout recovery mechanism: + * When a forced logout occurs (e.g., 401 unauthorized due to token invalidation), + * the client should preserve the encrypted vault locally. On next login with the same + * credentials, the client should detect the preserved vault and recover by uploading + * it to the server. + */ + @Test + fun test04ForcedLogoutRecovery() = runBlocking { + val testUser = createTestUser() + val uniqueName = TestConfiguration.generateUniqueName("Forced Logout Test") + println("[Test04] Testing forced logout recovery with item: $uniqueName") + + launchApp() + loginWithTestUser(testUser) + + // Step 1: Verify initial state + println("[Test04] Step 1: Verify initial state") + device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + + // Step 2: Create credential while online + println("[Test04] Step 2: Create credential while online") + val forcedLogoutItemParams = CreateItemParams( + name = uniqueName, + serviceUrl = "https://forced-logout-test.example.com", + email = "forced-logout-test@example.com", + ) + + assertTrue("Should create item", createItem(forcedLogoutItemParams)) + Thread.sleep(1000) // Wait for sync + + println("[Test04] Credential created and synced to server") + + val beforeLogoutRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val revisionBeforeLogout = beforeLogoutRevisions.second + println("[Test04] Server revision before forced logout: $revisionBeforeLogout") + + // Simulate server data loss + val deletedCount = TestUserRegistration.deleteVaultRevisionsByUsername(testUser.username, 1) + println("[Test04] Deleted $deletedCount vault revision(s) to simulate server data loss") + + val afterRollbackRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val revisionAfterRollback = afterRollbackRevisions.second + println("[Test04] Server revision after rollback: $revisionAfterRollback") + + // Step 3: Block user to trigger forced logout + println("[Test04] Step 3: Block user to trigger forced logout") + TestUserRegistration.blockUserByUsername(testUser.username) + println("[Test04] User blocked") + + // Step 4: Trigger sync to cause forced logout + println("[Test04] Step 4: Trigger sync to cause forced logout") + pullToRefresh() + + // Wait for session expired dialog and dismiss it + val okButton = device.waitForText("OK", TestConfiguration.DEFAULT_TIMEOUT_MS) + if (okButton != null) { + println("[Test04] Session expired modal detected, dismissing...") + okButton.click() + Thread.sleep(1000) + } else { + println("[Test04] No session expired modal detected, continuing...") + } + + // Step 5: Verify login screen + println("[Test04] Step 5: Verify login screen after forced logout") + device.assertTestIdExists("login-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + + // Verify username is prefilled (orphan vault preservation) + val usernameInput = device.findByTestId("username-input") + assertNotNull("Username input should be visible", usernameInput) + + val usernameValue = usernameInput?.text ?: "" + println("[Test04] Username field value: '$usernameValue'") + + println("[Test04] Forced logout confirmed - on login screen") + + // Unblock user so they can log in again + TestUserRegistration.unblockUserByUsername(testUser.username) + println("[Test04] User unblocked") + + // Step 6: Re-login with same credentials + println("[Test04] Step 6: Re-login with same credentials") + assertTrue("Should type username", device.typeIntoTestId("username-input", testUser.username)) + assertTrue("Should tap password input", device.tapTestId("password-input")) + assertTrue("Should type password", device.typeIntoTestId("password-input", testUser.password)) + + device.hideKeyboard() + assertTrue("Should tap login button", device.tapTestId("login-button")) + + // Wait for login to complete + device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + Thread.sleep(5000) // Wait for sync + + println("[Test04] Re-login successful") + + // Step 7: Verify credential still exists + println("[Test04] Step 7: Verify credential still exists") + assertTrue( + "Credential should still exist after forced logout recovery", + openAndVerifyItem(uniqueName, "forced-logout-test@example.com"), + ) + + // Verify server revision is restored + val finalRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username) + val finalRevision = finalRevisions.second + println("[Test04] Final server revision: $finalRevision") + + assertTrue( + "Server revision should be restored after forced logout recovery", + finalRevision >= revisionBeforeLogout, + ) + + println("[Test04] SUCCESS - Forced logout recovery verified!") + println("[Test04] Revision flow: $revisionBeforeLogout (before) → $revisionAfterRollback (rollback) → $finalRevision (recovered)") + } + + // endregion + + // region Helper Methods + + /** + * Logs in with test user at the beginning of a test. + * Always logs out first if already logged in to ensure we're using the correct test user. + */ + private fun loginWithTestUser(testUser: TestUser) { + // Wait for app to settle - poll for any expected screen + val startTime = System.currentTimeMillis() + val maxWaitTime = 15000L + + while (System.currentTimeMillis() - startTime < maxWaitTime) { + if (device.findByTestId("unlock-screen") != null || + device.findByTestId("login-screen") != null || + device.findByTestId("items-screen") != null + ) { + break + } + Thread.sleep(200) + } + + // Handle unlock screen - logout to start fresh + if (device.existsByTestId("unlock-screen")) { + println("[Helper] Unlock screen detected - logging out to login fresh with test user") + + if (device.tapTestId("logout-button")) { + // Handle logout confirmation + val confirmButton = device.waitForText("Logout", TestConfiguration.SHORT_TIMEOUT_MS) + confirmButton?.click() + } + + device.waitForTestId("login-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) + } + + // Check if we're on login screen + if (device.existsByTestId("login-screen")) { + performLogin(testUser) + return + } + + // Check if already on items screen + if (device.existsByTestId("items-screen")) { + println("[Helper] Already on items screen, assuming correct user is logged in") + return + } + + println("[Helper] Unknown app state after waiting, test may fail") + } + + /** + * Unlocks the vault if the unlock screen is displayed. + */ + private fun unlockVaultIfNeeded(testUser: TestUser) { + val unlockScreen = device.waitForTestId("unlock-screen", TestConfiguration.SHORT_TIMEOUT_MS) + if (unlockScreen == null) { + if (device.existsByTestId("items-screen")) { + println("[Helper] Already on items screen, no unlock needed") + } else { + println("[Helper] Not on unlock or items screen, proceeding anyway") + } + return + } + + println("[Helper] Unlock screen detected - entering password to unlock") + + device.tapTestId("unlock-password-input") + device.typeIntoTestId("unlock-password-input", testUser.password) + device.hideKeyboard() + device.tapTestId("unlock-button") + + device.waitForTestId("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + } + + /** + * Performs login with the given test user credentials. + */ + private fun performLogin(testUser: TestUser) { + // Check if API URL is already configured to localhost + val serverUrlLink = device.findByTestId("server-url-link") + var needsApiConfig = true + + if (serverUrlLink != null) { + val urlText = serverUrlLink.text ?: "" + if (urlText.contains("localhost")) { + println("[Helper] API URL already configured to localhost, skipping API configuration") + needsApiConfig = false + } else { + println("[Helper] API URL shows '$urlText', need to configure to localhost") + } + } + + if (needsApiConfig) { + if (device.tapTestId("server-url-link-button") || device.tapTestId("server-url-link")) { + if (device.waitForTestId("api-option-custom", TestConfiguration.DEFAULT_TIMEOUT_MS) != null) { + device.tapTestId("api-option-custom") + + if (device.waitForTestId("custom-api-url-input") != null) { + device.tapTestId("custom-api-url-input") + device.typeIntoTestId("custom-api-url-input", TestConfiguration.apiUrl) + println("[Helper] Configured API URL: ${TestConfiguration.apiUrl}") + } + + device.hideKeyboard() + device.tapTestId("back-button") + } + } + } + + // Wait for login screen + device.waitForTestId("login-screen") + + // Enter credentials + device.typeIntoTestId("username-input", testUser.username) + device.tapTestId("password-input") + device.typeIntoTestId("password-input", testUser.password) + device.hideKeyboard() + device.tapTestId("login-button") + + // Wait for items screen + device.waitForTestId("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS) + } + + /** + * Parameters for creating a new item. + */ + data class CreateItemParams( + val name: String, + val serviceUrl: String = "https://example.com", + val email: String = "test@example.com", + val username: String? = null, + ) + + /** + * Creates a new item with the given parameters. + */ + private fun createItem(params: CreateItemParams): Boolean { + println("[Helper] Creating item: ${params.name}") + + // Tap add button + if (!device.tapTestId("add-item-button")) { + println("[Helper] Failed to tap add button") + return false + } + + if (device.waitForTestId("add-edit-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) { + println("[Helper] Add/edit screen did not appear") + return false + } + + // Fill item name + device.scrollToTestId("item-name-input") + device.tapTestId("item-name-input") + device.typeIntoTestId("item-name-input", params.name) + + // Fill service URL + device.scrollToTestId("service-url-input") + device.tapTestId("service-url-input") + device.typeIntoTestId("service-url-input", params.serviceUrl) + + // Add email + device.scrollToTestId("add-email-button") + device.tapTestId("add-email-button") + + device.scrollToTestId("login-email-input") + device.tapTestId("login-email-input") + device.typeIntoTestId("login-email-input", params.email) + + // Optionally add username + if (params.username != null) { + device.scrollToTestId("login-username-input") + if (device.existsByTestId("login-username-input")) { + device.tapTestId("login-username-input") + device.typeIntoTestId("login-username-input", params.username) + } + } + + device.hideKeyboard() + + // Save item + device.tapTestId("save-button") + + if (device.waitForText("Login credentials", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) { + println("[Helper] Item detail screen did not appear after save") + return false + } + + // Return to items list + Thread.sleep(1000) + device.tapTestId("back-button") + + if (device.waitForTestId("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) { + println("[Helper] Did not return to items screen") + return false + } + + // Wait for list to populate + Thread.sleep(2000) + + println("[Helper] Item '${params.name}' created successfully") + return true + } + + /** + * Verifies that an item with the given name exists in the items list. + */ + private fun verifyItemExistsInList(name: String): Boolean { + val itemFound = device.scrollToText(name) != null || + device.waitForText(name, TestConfiguration.DEFAULT_TIMEOUT_MS) != null + + if (!itemFound) { + println("[Helper] Item '$name' not found in list") + return false + } + + println("[Helper] Item '$name' found in list") + return true + } + + /** + * Opens an item from the list and verifies its details. + */ + private fun openAndVerifyItem(name: String, expectedEmail: String? = null): Boolean { + val itemCard = device.scrollToText(name) ?: device.findByText(name) + + if (itemCard == null) { + println("[Helper] Item '$name' not found in list") + return false + } + + itemCard.click() + + if (device.waitForText("Login credentials", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) { + println("[Helper] Item detail screen did not appear") + return false + } + + if (expectedEmail != null) { + if (device.waitForTextContains(expectedEmail) == null) { + println("[Helper] Expected email '$expectedEmail' not found") + return false + } + } + + println("[Helper] Item '$name' verified successfully") + return true + } + + // endregion +} diff --git a/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestConfiguration.kt b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestConfiguration.kt new file mode 100644 index 000000000..5f2ff766e --- /dev/null +++ b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestConfiguration.kt @@ -0,0 +1,49 @@ +package net.aliasvault.app + +/** + * Configuration for E2E UI tests. + */ +object TestConfiguration { + /** + * API URL for testing (defaults to local development server). + * Can be overridden by setting the API_URL instrumentation argument. + */ + val apiUrl: String + get() = System.getProperty("API_URL") ?: "http://10.0.2.2:5092" + + /** + * Generate a unique name for test items. + */ + fun generateUniqueName(prefix: String = "E2E Test"): String { + val timestamp = System.currentTimeMillis() + return "$prefix $timestamp" + } + + /** + * Default timeout for element waiting (milliseconds). + */ + const val DEFAULT_TIMEOUT_MS = 10_000L + + /** + * Extended timeout for operations that may take longer (like login with network). + */ + const val EXTENDED_TIMEOUT_MS = 30_000L + + /** + * Short timeout for quick checks (milliseconds). + */ + const val SHORT_TIMEOUT_MS = 2_000L + + /** + * Default Argon2Id encryption settings matching server defaults. + */ + object EncryptionDefaults { + const val TYPE = "Argon2Id" + const val ITERATIONS = 2 + const val MEMORY_SIZE = 19456 + const val PARALLELISM = 1 + + val settingsJson: String + get() = """{"DegreeOfParallelism":$PARALLELISM,"MemorySize":$MEMORY_SIZE,"Iterations":$ITERATIONS}""" + } +} diff --git a/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestUserRegistration.kt b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestUserRegistration.kt new file mode 100644 index 000000000..8cfad3b55 --- /dev/null +++ b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/TestUserRegistration.kt @@ -0,0 +1,523 @@ +package net.aliasvault.app + +import android.database.sqlite.SQLiteDatabase +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.aliasvault.app.vaultstore.models.VaultSql +import net.aliasvault.app.vaultstore.models.VaultVersions +import org.json.JSONArray +import org.json.JSONObject +import uniffi.aliasvault_core.argon2HashPassword +import uniffi.aliasvault_core.srpDerivePrivateKey +import uniffi.aliasvault_core.srpDeriveVerifier +import uniffi.aliasvault_core.srpGenerateSalt +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.KeyPairGenerator +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Test user credentials and token. + */ +data class TestUser( + val username: String, + val password: String, + val token: String, + val refreshToken: String, +) + +/** + * Token response from the API. + */ +data class TokenResponse( + val token: String, + val refreshToken: String, +) + +/** + * Test user registration helper using SRP protocol. + * Uses the Rust core library for SRP operations via UniFFI bindings. + */ +object TestUserRegistration { + private const val TAG = "TestUserRegistration" + + /** + * API URL for testing. + */ + val apiUrl: String + get() = TestConfiguration.apiUrl + + // region Username/Password Generation + + /** + * Generate a random test username. + */ + fun generateTestUsername(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + val randomPart = (1..10) + .map { chars.random() } + .joinToString("") + return "test_$randomPart@example.tld" + } + + /** + * Generate a test password. + * Uses a static password for easier debugging and test reproducibility. + */ + fun generateTestPassword(): String = "password" + + /** + * Normalize username by converting to lowercase and trimming whitespace. + */ + fun normalizeUsername(username: String): String = + username.lowercase().trim() + + // endregion + + // region Hex Conversion + + /** + * Convert ByteArray to uppercase hex string. + */ + fun bytesToHex(data: ByteArray): String = + data.joinToString("") { "%02X".format(it) } + + /** + * Convert hex string to ByteArray. + */ + fun hexToBytes(hex: String): ByteArray { + val cleanHex = hex.removePrefix("0x").removePrefix("0X").trim() + require(cleanHex.length % 2 == 0) { "Invalid hex string length" } + + return ByteArray(cleanHex.length / 2) { i -> + cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + // endregion + + // region Registration + + /** + * Register a new test user via the API using SRP protocol. + * Uses Rust core for all SRP operations. + */ + suspend fun registerTestUser( + apiBaseUrl: String, + username: String, + password: String, + ): TokenResponse = withContext(Dispatchers.IO) { + val baseUrl = apiBaseUrl.trimEnd('/') + "/v1/" + val normalizedUsername = normalizeUsername(username) + + // Generate salt using Rust core + val salt = srpGenerateSalt() + + // Derive key from password using Rust core Argon2 + val passwordHashHex = argon2HashPassword(password, salt) + + // Derive SRP private key and verifier using Rust core + val privateKey = srpDerivePrivateKey(salt, normalizedUsername, passwordHashHex) + val verifier = srpDeriveVerifier(privateKey) + + // Build registration request + val registerRequest = JSONObject().apply { + put("username", normalizedUsername) + put("salt", salt) + put("verifier", verifier) + put("encryptionType", TestConfiguration.EncryptionDefaults.TYPE) + put("encryptionSettings", TestConfiguration.EncryptionDefaults.settingsJson) + } + + // Send registration request + val connection = URL("${baseUrl}Auth/register").openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + + connection.outputStream.bufferedWriter().use { + it.write(registerRequest.toString()) + } + + if (connection.responseCode != 200) { + val errorBody = try { + connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + } catch (e: Exception) { + "HTTP ${connection.responseCode}" + } + throw Exception("Registration failed: $errorBody") + } + + val responseBody = connection.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + + val tokenResponse = TokenResponse( + token = json.getString("token"), + refreshToken = json.getString("refreshToken"), + ) + + // Upload initial empty vault + val encryptionKey = hexToBytes(passwordHashHex) + uploadInitialVault( + apiBaseUrl = apiBaseUrl, + token = tokenResponse.token, + username = normalizedUsername, + encryptionKey = encryptionKey, + ) + + tokenResponse + } finally { + connection.disconnect() + } + } + + // endregion + + // region Vault Upload + + /** + * Upload an initial empty vault to the server. + */ + private suspend fun uploadInitialVault( + apiBaseUrl: String, + token: String, + username: String, + encryptionKey: ByteArray, + ) = withContext(Dispatchers.IO) { + val baseUrl = apiBaseUrl.trimEnd('/') + "/v1/" + + // Create empty vault database + val vaultBase64 = createEmptyVaultDatabase() + + // Encrypt the vault using AES-GCM + val encryptedVault = symmetricEncrypt(vaultBase64, encryptionKey) + + // Generate RSA key pair for the vault + val rsaKeyPair = generateRsaKeyPair() + + // Get current timestamp in ISO8601 format + val now = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + .apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } + .format(java.util.Date()) + + // Build vault upload request + val vaultRequest = JSONObject().apply { + put("username", normalizeUsername(username)) + put("blob", encryptedVault) + put("version", VaultVersions.latestVersion) + put("currentRevisionNumber", VaultVersions.latestRevision) + put("encryptionPublicKey", rsaKeyPair.first) + put("credentialsCount", 0) + put("emailAddressList", JSONArray()) + put("privateEmailDomainList", JSONArray()) + put("hiddenPrivateEmailDomainList", JSONArray()) + put("publicEmailDomainList", JSONArray()) + put("createdAt", now) + put("updatedAt", now) + } + + val connection = URL("${baseUrl}Vault").openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", "Bearer $token") + connection.doOutput = true + + connection.outputStream.bufferedWriter().use { + it.write(vaultRequest.toString()) + } + + if (connection.responseCode != 200) { + val errorBody = try { + connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + } catch (e: Exception) { + "HTTP ${connection.responseCode}" + } + throw Exception("Failed to upload vault: $errorBody") + } + } finally { + connection.disconnect() + } + } + + // endregion + + // region Encryption Helpers + + /** + * Encrypt data using AES-GCM. + */ + private fun symmetricEncrypt(plaintext: String, key: ByteArray): String { + val iv = ByteArray(12) + SecureRandom().nextBytes(iv) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val keySpec = SecretKeySpec(key, "AES") + val gcmSpec = GCMParameterSpec(128, iv) + + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + val encrypted = cipher.doFinal(plaintext.toByteArray(StandardCharsets.UTF_8)) + + // Combined format: nonce + ciphertext + tag + val combined = ByteArray(iv.size + encrypted.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size) + + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + /** + * Generate RSA key pair for vault encryption. + */ + private fun generateRsaKeyPair(): Pair { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + + val publicKey = keyPair.public as java.security.interfaces.RSAPublicKey + val privateKey = keyPair.private as java.security.interfaces.RSAPrivateKey + + // Export as simple JWK format + val publicKeyJwk = JSONObject().apply { + put("kty", "RSA") + put("key_ops", JSONArray().put("encrypt")) + put("ext", true) + put( + "n", + Base64.encodeToString( + publicKey.modulus.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, + ), + ) + put( + "e", + Base64.encodeToString( + publicKey.publicExponent.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, + ), + ) + } + + val privateKeyJwk = JSONObject().apply { + put("kty", "RSA") + put("key_ops", JSONArray().put("decrypt")) + put("ext", true) + put( + "n", + Base64.encodeToString( + privateKey.modulus.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, + ), + ) + } + + return Pair(publicKeyJwk.toString(), privateKeyJwk.toString()) + } + + // endregion + + // region Empty Vault Creation + + /** + * Create an empty vault database as base64 string. + */ + private fun createEmptyVaultDatabase(): String { + val tempFile = File.createTempFile("vault", ".db") + try { + val db = SQLiteDatabase.openOrCreateDatabase(tempFile, null) + try { + // Execute the complete schema SQL + val statements = VaultSql.completeSchema.split(";") + for (statement in statements) { + val trimmed = statement.trim() + if (trimmed.isNotEmpty()) { + db.execSQL("$trimmed;") + } + } + } finally { + db.close() + } + + // Read the database file and encode as base64 + val dbBytes = tempFile.readBytes() + return Base64.encodeToString(dbBytes, Base64.NO_WRAP) + } finally { + tempFile.delete() + } + } + + // endregion + + // region Public API + + /** + * Create a test user with random credentials. + */ + suspend fun createTestUser(apiBaseUrl: String? = null): TestUser { + val url = apiBaseUrl ?: apiUrl + val username = generateTestUsername() + val password = generateTestPassword() + + val tokenResponse = registerTestUser( + apiBaseUrl = url, + username = username, + password = password, + ) + + return TestUser( + username = username, + password = password, + token = tokenResponse.token, + refreshToken = tokenResponse.refreshToken, + ) + } + + /** + * Check if the API is available. + */ + suspend fun isApiAvailable(apiBaseUrl: String? = null): Boolean = withContext(Dispatchers.IO) { + val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/" + + try { + val connection = URL("${url}Auth/status").openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.setRequestProperty("Content-Type", "application/json") + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + + val responseCode = connection.responseCode + connection.disconnect() + + // Status endpoint returns 401 when not authenticated, but that means API is running + responseCode == 401 || responseCode == 200 + } catch (e: Exception) { + Log.e(TAG, "API not available", e) + false + } + } + + // endregion + + // region Test Helpers (DEV API Endpoints) + + /** + * Get vault revision information for a user by username. + * This is an anonymous endpoint that doesn't require authentication. + */ + suspend fun getVaultRevisionsByUsername( + username: String, + apiBaseUrl: String? = null, + ): Pair = withContext(Dispatchers.IO) { + val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/" + val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8") + + val connection = + URL("${url}Test/vault-revisions/by-username/$encodedUsername").openConnection() as HttpURLConnection + try { + connection.requestMethod = "GET" + connection.setRequestProperty("Content-Type", "application/json") + + if (connection.responseCode != 200) { + throw Exception("Failed to get vault revisions: HTTP ${connection.responseCode}") + } + + val responseBody = connection.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + + Pair( + json.optInt("count", 0), + json.optInt("currentRevision", 0), + ) + } finally { + connection.disconnect() + } + } + + /** + * Delete the newest vault revisions for a user by username. + */ + suspend fun deleteVaultRevisionsByUsername( + username: String, + count: Int, + apiBaseUrl: String? = null, + ): Int = withContext(Dispatchers.IO) { + val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/" + val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8") + + val connection = + URL("${url}Test/vault-revisions/by-username/$encodedUsername/$count").openConnection() as HttpURLConnection + try { + connection.requestMethod = "DELETE" + connection.setRequestProperty("Content-Type", "application/json") + + if (connection.responseCode != 200) { + throw Exception("Failed to delete vault revisions: HTTP ${connection.responseCode}") + } + + val responseBody = connection.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + + json.optInt("deleted", 0) + } finally { + connection.disconnect() + } + } + + /** + * Block a user's account by username. + */ + suspend fun blockUserByUsername( + username: String, + apiBaseUrl: String? = null, + ) = withContext(Dispatchers.IO) { + val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/" + val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8") + + val connection = + URL("${url}Test/block-user/by-username/$encodedUsername").openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + + if (connection.responseCode != 200) { + throw Exception("Failed to block user: HTTP ${connection.responseCode}") + } + } finally { + connection.disconnect() + } + } + + /** + * Unblock a user's account by username. + */ + suspend fun unblockUserByUsername( + username: String, + apiBaseUrl: String? = null, + ) = withContext(Dispatchers.IO) { + val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/" + val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8") + + val connection = + URL("${url}Test/unblock-user/by-username/$encodedUsername").openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + + if (connection.responseCode != 200) { + throw Exception("Failed to unblock user: HTTP ${connection.responseCode}") + } + } finally { + connection.disconnect() + } + } + + // endregion +} diff --git a/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/UITestHelpers.kt b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/UITestHelpers.kt new file mode 100644 index 000000000..71295eca8 --- /dev/null +++ b/apps/mobile-app/android/app/src/androidTest/java/net/aliasvault/app/UITestHelpers.kt @@ -0,0 +1,399 @@ +package net.aliasvault.app + +import android.util.Log +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until + +/** + * UI test helper functions for Android instrumented tests. + * Provides utilities for interacting with React Native views via UI Automator. + */ +object UITestHelpers { + private const val TAG = "UITestHelpers" + + // region Element Finding + + /** + * Find an element by its testID. + * React Native on Android exposes testID via resource-id (without package prefix). + */ + fun UiDevice.findByTestId(testId: String): UiObject2? { + // Primary: resource-id without package prefix - this is where RN maps testID + return findObject(By.res(testId)) + } + + /** + * Find an element by text content. + */ + fun UiDevice.findByText(text: String): UiObject2? { + return findObject(By.text(text)) + } + + /** + * Find an element by text containing a substring. + */ + fun UiDevice.findByTextContains(text: String): UiObject2? { + return findObject(By.textContains(text)) + } + + // endregion + + // region Waiting + + /** + * Wait for an element with testID to exist. + * Uses resource-id without package prefix (By.res) which is where React Native maps testID. + */ + fun UiDevice.waitForTestId( + testId: String, + timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS, + ): UiObject2? { + // Primary: resource-id without package prefix - this is where RN maps testID + val result = wait(Until.findObject(By.res(testId)), timeout) + if (result == null) { + Log.w(TAG, "Timeout waiting for testId: $testId") + } + return result + } + + /** + * Wait for an element with text to exist. + */ + fun UiDevice.waitForText( + text: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): UiObject2? { + val result = wait(Until.findObject(By.text(text)), timeout) + if (result == null) { + Log.w(TAG, "Timeout waiting for text: $text") + } + return result + } + + /** + * Wait for an element with text containing substring to exist. + */ + fun UiDevice.waitForTextContains( + text: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): UiObject2? { + val result = wait(Until.findObject(By.textContains(text)), timeout) + if (result == null) { + Log.w(TAG, "Timeout waiting for text containing: $text") + } + return result + } + + /** + * Wait for an element to be gone. + */ + fun UiDevice.waitForTestIdGone( + testId: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + // Primary: resource-id without package prefix - this is where RN maps testID + return wait(Until.gone(By.res(testId)), timeout) ?: true + } + + /** + * Wait for text to be gone. + */ + fun UiDevice.waitForTextGone( + text: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + return wait(Until.gone(By.text(text)), timeout) ?: false + } + + // endregion + + // region Existence Checks + + /** + * Check if an element with testID exists. + */ + fun UiDevice.existsByTestId(testId: String): Boolean { + return findByTestId(testId) != null + } + + /** + * Check if an element with text exists. + */ + fun UiDevice.existsByText(text: String): Boolean { + return findByText(text) != null + } + + /** + * Check if an element with text containing substring exists. + */ + fun UiDevice.existsByTextContains(text: String): Boolean { + return findByTextContains(text) != null + } + + // endregion + + // region Actions + + /** + * Tap on an element with testID. + */ + fun UiDevice.tapTestId( + testId: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + val element = waitForTestId(testId, timeout) + return if (element != null) { + element.click() + true + } else { + Log.e(TAG, "Failed to tap testId: $testId - element not found") + false + } + } + + /** + * Tap on an element with text. + */ + fun UiDevice.tapText( + text: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + val element = waitForText(text, timeout) + return if (element != null) { + element.click() + true + } else { + Log.e(TAG, "Failed to tap text: $text - element not found") + false + } + } + + /** + * Type text into an element with testID. + */ + fun UiDevice.typeIntoTestId( + testId: String, + text: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + val element = waitForTestId(testId, timeout) + return if (element != null) { + element.click() + Thread.sleep(100) // Small delay for focus + element.text = text + true + } else { + Log.e(TAG, "Failed to type into testId: $testId - element not found") + false + } + } + + /** + * Clear text in an element with testID. + */ + fun UiDevice.clearTestId( + testId: String, + timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS, + ): Boolean { + val element = waitForTestId(testId, timeout) + return if (element != null) { + element.click() + Thread.sleep(100) // Small delay for focus + element.clear() + true + } else { + Log.e(TAG, "Failed to clear testId: $testId - element not found") + false + } + } + + // endregion + + // region Scrolling + + /** + * Scroll down to find an element with testID. + */ + fun UiDevice.scrollToTestId( + testId: String, + maxScrolls: Int = 5, + ): UiObject2? { + repeat(maxScrolls) { + findByTestId(testId)?.let { return it } + swipe( + displayWidth / 2, + displayHeight * 3 / 4, + displayWidth / 2, + displayHeight / 4, + 10, + ) + Thread.sleep(300) // Wait for scroll to settle + } + return findByTestId(testId) + } + + /** + * Scroll down to find an element with text. + */ + fun UiDevice.scrollToText( + text: String, + maxScrolls: Int = 5, + ): UiObject2? { + repeat(maxScrolls) { + findByText(text)?.let { return it } + swipe( + displayWidth / 2, + displayHeight * 3 / 4, + displayWidth / 2, + displayHeight / 4, + 10, + ) + Thread.sleep(300) // Wait for scroll to settle + } + return findByText(text) + } + + // endregion + + // region Navigation + + /** + * Navigate back using the device back button. + */ + fun UiDevice.navigateBack() { + pressBack() + Thread.sleep(500) // Wait for navigation animation + } + + /** + * Navigate home using the device home button. + */ + fun UiDevice.navigateHome() { + pressHome() + Thread.sleep(500) // Wait for navigation animation + } + + // endregion + + // region Assert Helpers + + /** + * Assert that an element with testID exists. + */ + fun UiDevice.assertTestIdExists( + testId: String, + timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS, + ) { + val element = waitForTestId(testId, timeout) + if (element == null) { + throw AssertionError("Expected element with testId '$testId' to exist, but it was not found") + } + } + + /** + * Assert that an element with text exists. + */ + fun UiDevice.assertTextExists( + text: String, + timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS, + ) { + val element = waitForText(text, timeout) + if (element == null) { + throw AssertionError("Expected element with text '$text' to exist, but it was not found") + } + } + + /** + * Assert that an element with text containing substring exists. + */ + fun UiDevice.assertTextContains( + text: String, + timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS, + ) { + val element = waitForTextContains(text, timeout) + if (element == null) { + throw AssertionError( + "Expected element containing text '$text' to exist, but it was not found", + ) + } + } + + /** + * Assert that an element with testID does not exist. + */ + fun UiDevice.assertTestIdNotExists(testId: String) { + val element = findByTestId(testId) + if (element != null) { + throw AssertionError("Expected element with testId '$testId' to NOT exist, but it was found") + } + } + + /** + * Assert that an element with text does not exist. + */ + fun UiDevice.assertTextNotExists(text: String) { + val element = findByText(text) + if (element != null) { + throw AssertionError("Expected element with text '$text' to NOT exist, but it was found") + } + } + + // endregion + + // region Text Field Helpers + + /** + * Get the text value from an element with testID. + */ + fun UiDevice.getTextFromTestId(testId: String): String? { + return findByTestId(testId)?.text + } + + /** + * Check if a text field with testID has specific text. + */ + fun UiDevice.testIdHasText(testId: String, expectedText: String): Boolean { + return findByTestId(testId)?.text == expectedText + } + + // endregion + + // region Keyboard + + /** + * Hide the keyboard if visible. + */ + fun UiDevice.hideKeyboard() { + pressBack() + Thread.sleep(200) + } + + // endregion + + // region Sleep Helpers + + /** + * Short sleep for UI to update. + */ + fun shortSleep() { + Thread.sleep(500) + } + + /** + * Medium sleep for animations. + */ + fun mediumSleep() { + Thread.sleep(1000) + } + + /** + * Long sleep for network operations. + */ + fun longSleep() { + Thread.sleep(2000) + } + + // endregion +} diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index 1f67fa149..79596082f 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -74,7 +74,6 @@ "eslint-config-expo": "~9.2.0", "eslint-plugin-jsdoc": "^55.2.0", "eslint-plugin-react-native": "^5.0.0", - "expo-dev-client": "~5.1.8", "globals": "^16.3.0", "jest": "^29.2.1", "jest-expo": "~53.0.0", @@ -8775,86 +8774,6 @@ "react-native": "*" } }, - "node_modules/expo-dev-client": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.1.8.tgz", - "integrity": "sha512-IopYPgBi3JflksO5ieTphbKsbYHy9iIVdT/d69It++y0iBMSm0oBIoDmUijrHKjE3fV6jnrwrm8luU13/mzIQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expo-dev-launcher": "5.1.11", - "expo-dev-menu": "6.1.10", - "expo-dev-menu-interface": "1.10.0", - "expo-manifests": "~0.16.4", - "expo-updates-interface": "~1.1.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-launcher": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.1.11.tgz", - "integrity": "sha512-bN0+nv5H038s8Gzf8i16hwCyD3sWDmHp7vb+QbL1i6B3XNnICCKS/H/3VH6H3PRMvCmoLGPlg+ODDqGlf0nu3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.11.0", - "expo-dev-menu": "6.1.10", - "expo-manifests": "~0.16.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-launcher/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/expo-dev-launcher/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/expo-dev-menu": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.1.10.tgz", - "integrity": "sha512-LaI0Bw5zzw5XefjYSX6YaMydzk0YBysjqQoxzj6ufDyKgwAfPmFwOLkZ03DOSerc9naezGLNAGgTEN6QTgMmgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expo-dev-menu-interface": "1.10.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-menu-interface": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz", - "integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-document-picker": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.1.6.tgz", @@ -8896,13 +8815,6 @@ "expo": "*" } }, - "node_modules/expo-json-utils": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", - "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/expo-keep-awake": { "version": "14.1.4", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", @@ -8963,20 +8875,6 @@ "react": "*" } }, - "node_modules/expo-manifests": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz", - "integrity": "sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config": "~11.0.12", - "expo-json-utils": "~0.15.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-modules-autolinking": { "version": "2.1.14", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz", @@ -9115,16 +9013,6 @@ "react-native": "*" } }, - "node_modules/expo-updates-interface": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz", - "integrity": "sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-web-browser": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.2.0.tgz", diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index d06105841..e65ddacdf 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -95,7 +95,6 @@ "eslint-config-expo": "~9.2.0", "eslint-plugin-jsdoc": "^55.2.0", "eslint-plugin-react-native": "^5.0.0", - "expo-dev-client": "~5.1.8", "globals": "^16.3.0", "jest": "^29.2.1", "jest-expo": "~53.0.0",