diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml new file mode 100644 index 0000000000..6c8fd6003d --- /dev/null +++ b/.github/workflows/maestro.yml @@ -0,0 +1,104 @@ +name: Maestro E2E + +# Real-backend E2E against the shared test account, so it is not wired to every PR by +# default: run it on demand (choose tags) and nightly (smoke). To gate PRs, add a +# `pull_request:` trigger below — note it needs the test-account secrets, so it won't run +# on fork PRs. +on: + workflow_dispatch: + inputs: + tags: + description: "Maestro include-tags (e.g. smoke, tipping, gate)" + default: "smoke" + exclude_tags: + description: "Maestro exclude-tags (side-effecting flows excluded by default)" + default: "spends-funds,creates-account" + schedule: + - cron: "37 7 * * *" # nightly, off the top of the hour + +concurrency: + group: maestro-${{ github.ref }} + cancel-in-progress: true + +jobs: + maestro: + name: Maestro E2E (${{ github.event.inputs.tags || 'smoke' }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Java env + uses: actions/setup-java@v3 + with: + java-version: "21" + distribution: "corretto" + cache: "gradle" + + - name: Setup Ruby env + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7.2 + bundler-cache: true + + # Build prerequisites (same as the unit-test CI job). + - name: Decode Google Services JSON file + uses: timheuer/base64-to-file@v1 + with: + fileName: google-services.json + fileDir: ./apps/flipcash/app/src + encodedString: ${{ secrets.FLIPCASH2_GOOGLE_SERVICES }} + - name: Setup local.properties API keys + run: | + { + echo "BUGSNAG_API_KEY=\"${{ secrets.FLIPCASH_BUGSNAG_API_KEY }}\"" + echo "MIXPANEL_API_KEY=\"${{ secrets.FLIPCASH_MIXPANEL_API_KEY }}\"" + echo "COINBASE_ONRAMP_API_KEY=${{ secrets.COINBASE_ONRAMP_API_KEY }}" + echo "GOOGLE_CLOUD_PROJECT_NUMBER=${{ secrets.GOOGLE_CLOUD_PROJECT_NUMBER }}" + } >> ./local.properties + + - name: Install Maestro CLI + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + # KVM is required for a fast x86_64 emulator on GitHub-hosted Linux runners. + - name: Enable KVM + 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 Maestro suite on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim + disable-animations: true + script: bundle exec fastlane android flipcash_maestro + env: + MAESTRO_TAGS: ${{ github.event.inputs.tags || 'smoke' }} + MAESTRO_EXCLUDE_TAGS: ${{ github.event.inputs.exclude_tags || 'spends-funds,creates-account' }} + # Test-account credentials (map GitHub secrets -> the env vars run.sh reads). + SEED_PHRASE: ${{ secrets.MAESTRO_SEED_PHRASE }} + LOGIN_DEEPLINK: ${{ secrets.MAESTRO_LOGIN_DEEPLINK }} + TIPCARD_DEEPLINK: ${{ secrets.MAESTRO_TIPCARD_DEEPLINK }} + USDF_ONLY_DEEPLINK: ${{ secrets.MAESTRO_USDF_ONLY_DEEPLINK }} + CONTACT_NAME: ${{ secrets.MAESTRO_CONTACT_NAME }} + CONTACT_PHONE: ${{ secrets.MAESTRO_CONTACT_PHONE }} + + - name: Upload Maestro report + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-report + path: | + maestro-report.xml + ~/.maestro/tests/** + if-no-files-found: ignore diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index 03501dfef4..2ed746a1f5 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -29,6 +29,7 @@ import com.flipcash.app.core.verification.email.EmailCodeChannel import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.onramp.CoinbaseOnRampController +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.internal.ui.App @@ -151,6 +152,8 @@ class MainActivity : FragmentActivity() { // the UI thread building the country list. lifecycleScope.launch(Dispatchers.Default) { phoneUtils.ensureLoaded() } + applyBetaFlagLaunchOverrides() + setContent { CompositionLocalProvider( LocalResources provides resources, @@ -187,8 +190,33 @@ class MainActivity : FragmentActivity() { } } + /** + * Test-only: enable beta flags passed as a launch argument, so flag-gated features + * (tipping, blocklist, …) can be exercised in UI tests without toggling them in the + * Labs UI. Mirrors iOS's `--beta-flags`. Debug/UI-test builds only. + * + * launchApp: + * arguments: + * betaFlags: "tipping_enabled,blocklist_enabled" + * + * The value is a comma-separated list of [FeatureFlag.key]s. + */ + private fun applyBetaFlagLaunchOverrides() { + if (!BuildConfig.UI_TESTABLE) return + intent.getStringExtra(BETA_FLAGS) + .orEmpty() + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { key -> + FeatureFlag.entries.firstOrNull { it.key == key } + ?.let { featureFlagController.set(it, true) } + } + } + companion object { private const val UI_TEST = "isUiTest" + private const val BETA_FLAGS = "betaFlags" } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index fd66432a92..01a33f4cf7 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -92,7 +92,8 @@ fun appEntryProvider( annotatedEntry { key -> InviteContactScreen(key.phoneNumber) } // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) - annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } + // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. + annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) } annotatedEntry { SendFlowScreen(resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) @@ -109,10 +110,12 @@ fun appEntryProvider( } // Tokens - annotatedEntry { key -> + annotatedEntry(testTag = "token_info_screen") { key -> TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink) } - annotatedEntry { key -> TransactionHistoryScreen(key.mint) } + annotatedEntry(testTag = "transaction_history_screen") { key -> + TransactionHistoryScreen(key.mint) + } annotatedEntry { key -> SwapFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 29eb7371d9..a9f0e78bdd 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -253,7 +253,9 @@ internal fun resolvePostAccountRoute( private fun onboardingEntryProvider( route: AppRoute.OnboardingFlow, ): (NavKey) -> NavEntry = entryProvider { - annotatedEntry { step -> + // The pre-login landing; flows/screenshots anchor on `login_screen` rather than + // the step-derived `start_screen`. + annotatedEntry(testTag = "login_screen") { step -> LoginStepContent(step.seed) } annotatedEntry { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 934bc0d390..ad61b45ff6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -114,8 +114,7 @@ private fun ChatInputScaffold( // which made the message list visibly jump on every open and every pop-back. SubcomposeLayout( modifier = Modifier - .imePadding() - .testTag("chat_screen"), + .imePadding(), ) { constraints -> val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index f06d27c296..0a9458730e 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.painterResource @@ -84,7 +83,6 @@ private fun TokenInfoScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .testTag("token_info_screen") .padding( start = innerPadding.calculateStartPadding(), end = innerPadding.calculateEndPadding(), diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt index 932391875e..7af45aab7b 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt @@ -90,13 +90,15 @@ private fun withdrawalEntryProvider( annotatedEntry { WithdrawalSelectTokenScreen() } - annotatedEntry { step -> + // Explicit tags: step names (Amount/Destination/Confirmation) are generic and collide + // with other flows' steps, so give the E2E-targeted steps stable, unambiguous ids. + annotatedEntry(testTag = "withdraw_entry_screen") { step -> WithdrawalEntryScreen(step.mint) } - annotatedEntry { + annotatedEntry(testTag = "withdraw_destination_screen") { WithdrawalDestinationScreen() } - annotatedEntry { + annotatedEntry(testTag = "withdraw_confirmation_screen") { WithdrawalConfirmationScreen() } } diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt index a132bc0cbe..11ee5a121a 100644 --- a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt +++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt @@ -51,6 +51,7 @@ internal fun purchaseOptions( width = 150.sp, height = 20.sp, tintIcon = false, + testTag = "purchase_method_coinbase", onClick = { onClick(PurchaseMethod.CoinbaseOnRamp) } ) ) @@ -76,6 +77,7 @@ internal fun purchaseOptions( suffix = resources.getString(R.string.label_phantom), iconPadding = { PaddingValues() }, iconRes = R.drawable.ic_phantom_wallet, + testTag = "purchase_method_phantom", onClick = { onClick(PurchaseMethod.PhantomWallet) } ) ) @@ -84,6 +86,7 @@ internal fun purchaseOptions( add( BottomBarAction( text = resources.getString(R.string.title_onrampProviderOtherWallet), + testTag = "purchase_method_other_wallet", onClick = { onClick(PurchaseMethod.OtherWallet) } ) ) @@ -111,9 +114,11 @@ private fun buildButtonAction( ) }, tintIcon: Boolean = true, + testTag: String? = null, onClick: () -> Unit ): BottomBarAction { return BottomBarAction( + testTag = testTag, text = buildAnnotatedString { if (prefix != null) { append(prefix) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0631e879cc..c3ee5072e1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -26,6 +26,17 @@ platform :android do ) end + desc "Run the Maestro E2E suite on a booted emulator (default tag: smoke)" + # Installs the debug build, then runs maestro/run.sh in tag mode. Test-account + # creds come from the environment (SEED_PHRASE, LOGIN_DEEPLINK, TIPCARD_DEEPLINK, + # USDF_ONLY_DEEPLINK, CONTACT_NAME, CONTACT_PHONE) — supplied by CI secrets or + # maestro/.env locally. Filter with MAESTRO_TAGS / MAESTRO_EXCLUDE_TAGS. + lane :flipcash_maestro do + gradle(task: ":apps:flipcash:app:installDebug") + tags = ENV.fetch("MAESTRO_TAGS", "smoke") + sh("cd .. && maestro/run.sh --tags #{tags.shellescape}") + end + desc "Build a new version of Flipcash" lane :build_flipcash do gradle( diff --git a/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt index 68055b6e0c..e31419906b 100644 --- a/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt +++ b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt @@ -12,6 +12,8 @@ data class BottomBarAction( val style: BottomBarManager.BottomBarButtonStyle = BottomBarManager.BottomBarButtonStyle.Filled, val isUser: Boolean = true, val enabled: Boolean = true, + // Optional UI-test anchor; surfaced as a resource-id when testTagsAsResourceId is on. + val testTag: String? = null, val onClick: () -> Unit = { } ) { constructor( @@ -19,6 +21,7 @@ data class BottomBarAction( style: BottomBarManager.BottomBarButtonStyle = BottomBarManager.BottomBarButtonStyle.Filled, isUser: Boolean = true, enabled: Boolean = true, + testTag: String? = null, onClick: () -> Unit = { } ) : this( text = AnnotatedString(text), @@ -26,6 +29,7 @@ data class BottomBarAction( style = style, isUser = isUser, enabled = enabled, + testTag = testTag, onClick = onClick ) diff --git a/maestro/README.md b/maestro/README.md new file mode 100644 index 0000000000..763874582c --- /dev/null +++ b/maestro/README.md @@ -0,0 +1,199 @@ +# Maestro E2E UI tests + +End-to-end UI flows that drive the real app on an emulator/device, in the spirit of +iOS's `FlipcashUITests`. Flows are plain YAML under `maestro/`; reusable pieces live in +`subflows/` and `helpers/`. + +## Prerequisites + +1. A booted emulator (or attached device). The suite defaults to `emulator-5554`. +2. The **debug** app installed: + ```bash + ANDROID_SERIAL=emulator-5554 ./gradlew :apps:flipcash:app:installDebug + ``` + The debug build sets `testTagsAsResourceId = true` (guarded by `BuildConfig.UI_TESTABLE` + in `App.kt`), which exposes Compose `testTag`s as resource-ids that Maestro targets with + `id:`. Release builds do **not** expose them. +3. The [Maestro CLI](https://maestro.mobile.dev) on your `PATH` (`maestro --version`). +4. Test-account credentials in `maestro/.env` (git-ignored): + ``` + SEED_PHRASE=word1 word2 ... word12 # primary account (tip-enabled) + LOGIN_DEEPLINK=https://app.flipcash.com/login?data=... # same account as SEED_PHRASE + TIPCARD_DEEPLINK=https://app.flipcash.com/tip/... # the primary account's tip card + USDF_ONLY_DEEPLINK=https://app.flipcash.com/login?data=... # reserves-only gate account + CONTACT_NAME=Brandon McAnsh # an on-Flipcash contact for send-to-contact + CONTACT_PHONE=+15869802333 # seed this contact into the emulator + ``` + The runner (`run.sh`) forwards all of these to Maestro. + +## Running + +Use the runner — it loads `.env`, approves App Links, and targets the device: + +```bash +maestro/run.sh maestro/account_navigation.yaml +maestro/run.sh maestro/wallet_token_info.yaml maestro/view_token_info.yaml +DEVICE=emulator-5556 maestro/run.sh maestro/account_navigation.yaml # pick a device +``` + +Or invoke Maestro directly: + +```bash +maestro --device emulator-5554 test \ + -e SEED_PHRASE="..." -e LOGIN_DEEPLINK="..." maestro/account_navigation.yaml +``` + +### App Links gotcha (fresh installs) + +A freshly-installed debug build has **unverified** App Links, so `https://app.flipcash.com/...` +deeplinks open in Chrome instead of the app (you'll see "Cannot GET /login"). Approve them once +per install (the runner does this automatically): + +```bash +adb -s emulator-5554 shell pm set-app-links --package com.flipcash.app.android 2 all +``` + +### Login + +Prefer **deeplink login** (`subflows/login_with_deeplink.yaml`): it clears state and logs the +test account straight to the scanner, so every flow starts from a deterministic home screen. +Seed login (`subflows/login.yaml`) assumes a logged-out start and is only for exercising the +login screen itself. + +## Screen-root test anchors (how tagging works) + +Every routed screen is addressable by a stable `_screen` resource-id. These are applied +**centrally**, at the single place every destination is registered — `annotatedEntry` in +`AppScreenContent.kt` — not scattered across screen composables: + +- The tag defaults to one **derived from the route type name** (`screenRootTag` in + `NavMetadata.kt`): `AppRoute.Menu.MyAccount` → `my_account_screen`, + `AppRoute.Main.Scanner` → `scanner_screen`. +- Pass an explicit `testTag` only when a route needs a different id than its type name, e.g. + `annotatedEntry(testTag = "cash_screen") { ... }`. + +Because the tag lives with the route registration, adding a screen tags it automatically and +the anchors can't drift out of sync with the UI. Screens that are **not** nav entries (the +pre-login landing, inner FlowHost steps like the withdrawal wizard) still need a manual +`testTag` on their root — e.g. `login_screen` in `LoginScreenContent.kt`. + +Sub-element anchors (buttons, lists, inputs) remain plain `testTag`s in the component code — +e.g. `menu_button`, `market_cap_chart`, `chat_message_list`, `send_contact_list`, `keypad_`. + +## Enabling beta flags from a test + +Beta-gated features (Tipping, Blocklist, …) can be turned on **at launch** without toggling +them in the Labs UI — mirroring iOS's `--beta-flags`. Pass a `betaFlags` launch argument (a +comma-separated list of `FeatureFlag.key`s); `MainActivity` reads it on debug/UI-test builds +and force-enables those flags: + +```yaml +- launchApp: + arguments: + isUiTest: true + betaFlags: "tipping_enabled,blocklist_enabled" +``` + +The overrides must be applied in the **same process** that renders the feature — deeplink +login relaunches via `openLink` and would drop the argument. So use one of: +- `subflows/login_with_flags.yaml` — seed login into the **existing** account with flags set. +- `subflows/create_account.yaml` — a brand-new account through onboarding (test phone + `+1 (500) 555-0000`, all-zero OTP), for one-run-per-account setup like the tip card. Both + take a `BETA_FLAGS` env var; the runner forwards `BETA_FLAGS` from your shell. + +```bash +BETA_FLAGS=tipping_enabled maestro/run.sh maestro/tipping_setup.yaml +``` + +## Coverage + +**Verified green** (run any of these with `maestro/run.sh`): +- `login_logout.yaml` — real seed-login UI + logout (Log Out lives on My Account) +- `account_navigation.yaml` — menu → My Account → App Settings +- `wallet_token_info.yaml` — wallet → token info + market-cap chart +- `discovery_leaderboard.yaml` — Discover → leaderboard → token info +- `direct_send.yaml` — send entry → phone gate +- `withdraw.yaml` — menu → Withdraw Money → USDC → amount entry (fund-safe) +- `deposit.yaml` — menu → Add Money → Other Wallet → USDC deposit (fund-safe) +- `tipping_setup.yaml` — create account (beta flag) → set up tip card → tip card renders +- `tip_chat.yaml` — open the tip conversation from the Tips tab and send a message +- `blocking.yaml` — block a chat participant from their profile, verify in My Account → + Blocked, then unblock (leaves the account clean) +- `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow + (waits for balances to sync first, else the empty-cache state trips the add-money gate) +- `buy.yaml` — token info → Buy → payment currency → confirm-purchase screen (fund-safe) +- `sell.yaml` — token info → Sell → amount entry (fund-safe) +- `currency_creator.yaml` — Discover → Create Your Own Currency → intro + $20 balance gate +- `coinbase_onramp.yaml` — Add Money → Coinbase/Google Pay method → onramp (phone verify); + `coinbase_onramp_sandbox_enabled` set so a follow-up can drive a sandbox purchase +- Give/bill round-trip, token-info deeplink, screenshot suite (existing) + +**Scaffolded — pending account provisioning** (flow authored + wired; drop in the account/contact +and it runs): +- `usdf_only_gate.yaml` — reserves-only account: tapping Cash routes to Discover ("No Community + Currencies Yet"). Mirrors iOS `GiveDiscoverGateRegressionTests`. Needs `USDF_ONLY_DEEPLINK` + (a dedicated USDF-only account, like iOS's `FLIPCASH_UI_TEST_USDF_ONLY_ACCESS_KEY`). +- `send_to_contact.yaml` — send to an on-Flipcash contact (mirrors iOS `SendSmokeTests`, which uses a + fixed contact "Raul Riera"). Parameterized by `CONTACT_NAME`/`CONTACT_PHONE`; **the runner seeds this + contact into the emulator automatically** (idempotent). The only remaining requirement is a + **send-enabled account** — i.e. a phone linked to the account (see below), and `CONTACT_PHONE` mapping + to a real Flipcash user. + +### Two phone-verification paths + +- **Onboarding / account creation** uses the **backend test number** `+15005550000` with OTP `000000` + (`create_account.yaml`). This is a backend test hook — no real SMS, no linkable identity. +- **Linking a phone to enable the send flow** — status: **blocked on code delivery.** What's verified: + - A valid-format number is required (the emulator's own `555-521-5554` is an invalid NPA and is + rejected at phone entry). A number like `+1 415-555-0100` is accepted and the code is requested. + - The app uses Android's **SMS User Consent** reader: an SMS delivered via + `adb emu sms send "…code is 123456"` lands in the inbox and the app prompts to read it and + auto-fills the code. **This path works.** + - **But the backend validates the real code** — an injected placeholder is rejected + ("Please enter a valid code"). The real code is sent to the entered number, which does **not** route + to the emulator, so it never arrives and can't be read. + - **To unblock:** the dev/staging backend must route the verification SMS for the test number **to this + emulator** (e.g. a webhook that calls `adb emu sms send`), so the real code lands in the inbox and the + app reads it. Once that exists, phone-linking is one-time per account and `send_to_contact.yaml` runs + green (contact is auto-seeded by the runner). +- **Full Coinbase purchase** — the flow reaches the onramp; completing it needs phone verification + (which links a phone to the shared account and would flip the send flows) plus driving the Google + Pay sandbox sheet. Note: **iOS doesn't automate the payment either** — its E2E stops at the same + onramp/verification boundary (`BuyApplePayRegressionTests`: unverified → verification sheet) and + covers order-building/deposit/verification logic with unit tests (`OnrampOrderRequestTests`, + `CoinbaseDepositOperationTests`, `OnrampVerificationViewModelTests`). So our `coinbase_onramp` entry + test is at parity; the sandbox flag + method tag are in place if we later want to go further. + +**Roadmap (tooling):** +- Buy/Sell/Withdraw past confirmation on a funded account (screens tagged). +- Wire a `flipcash_maestro` Fastlane lane (see below). + +## CI + +Wired via the **`flipcash_maestro`** Fastlane lane and the **`.github/workflows/maestro.yml`** +workflow: + +- The lane installs the debug build and runs `maestro/run.sh --tags ` (default + `smoke`, excluding `spends-funds,creates-account`), emitting a JUnit report. + +Side-effecting flows are tagged so runs stay clean: `spends-funds` (moves money) and +`creates-account` (onboards a new account, e.g. `tipping_setup.yaml`) are **excluded by +default**. `smoke` contains only read-only / fund-safe navigation. To run an account-creating +flow deliberately, clear the exclude, e.g. `MAESTRO_TAGS=tipping MAESTRO_EXCLUDE_TAGS= maestro/run.sh --tags tipping`. +- The workflow boots a KVM `x86_64` emulator (`reactivecircus/android-emulator-runner`), sets up + the same build secrets as the unit-test job, installs the Maestro CLI, runs the lane, and uploads + the report. +- Triggers: **`workflow_dispatch`** (choose `tags`/`exclude_tags`) and a **nightly schedule** + (smoke). It's real-backend E2E against the shared account, so it's deliberately not on every PR; + add a `pull_request:` trigger to gate PRs (won't run on fork PRs, which lack secrets). + +Run locally the same way CI does: +```bash +MAESTRO_TAGS=smoke maestro/run.sh --tags smoke +``` + +**Required GitHub secrets** (test-account creds — the workflow maps them to the env vars +`run.sh` reads): `MAESTRO_SEED_PHRASE`, `MAESTRO_LOGIN_DEEPLINK`, `MAESTRO_TIPCARD_DEEPLINK`, +`MAESTRO_USDF_ONLY_DEEPLINK`, `MAESTRO_CONTACT_NAME`, `MAESTRO_CONTACT_PHONE` — plus the existing +build secrets (`FLIPCASH2_GOOGLE_SERVICES`, `FLIPCASH_BUGSNAG_API_KEY`, `FLIPCASH_MIXPANEL_API_KEY`, +`COINBASE_ONRAMP_API_KEY`, `GOOGLE_CLOUD_PROJECT_NUMBER`). diff --git a/maestro/account_navigation.yaml b/maestro/account_navigation.yaml new file mode 100644 index 0000000000..b5c2817593 --- /dev/null +++ b/maestro/account_navigation.yaml @@ -0,0 +1,29 @@ +appId: com.flipcash.app.android +name: "Account & Settings Navigation" +tags: + - smoke + - account +--- +# Deterministic clean login → home. +- runFlow: subflows/login_with_deeplink.yaml + +# Menu → My Account +- runFlow: subflows/navigate_to_menu.yaml +- tapOn: "My Account" +- extendedWaitUntil: + visible: + id: my_account_screen + timeout: 10000 + +# Back to menu, then App Settings +- tapOn: + id: action_back +- extendedWaitUntil: + visible: + id: menu_screen + timeout: 10000 +- tapOn: "App Settings" +- extendedWaitUntil: + visible: + id: app_settings_screen + timeout: 10000 diff --git a/maestro/blocking.yaml b/maestro/blocking.yaml new file mode 100644 index 0000000000..a8979c44ab --- /dev/null +++ b/maestro/blocking.yaml @@ -0,0 +1,42 @@ +appId: com.flipcash.app.android +name: "Blocking — block & unblock a chat participant" +tags: + - blocklist +--- +# Enable tipping (to reach the tip chat) + blocklist at launch. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "tipping_enabled,blocklist_enabled" + +# Open the tip conversation. +- tapOn: "Tips" +- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- tapOn: { id: send_contact_row, index: 0 } +- extendedWaitUntil: { visible: { id: chat_screen }, timeout: 8000 } + +# Open the participant's profile from the chat header and block them. +# (Participant name is specific to the test account's tip chat.) +- tapOn: { text: "Brandon McAnsh", index: 0 } +- extendedWaitUntil: { visible: { id: profile_screen }, timeout: 8000 } +- tapOn: "Block" +- assertVisible: "Block Brandon McAnsh?" +- tapOn: { text: "^Block$" } +- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } + +# Verify they appear in My Account -> Blocked. +- tapOn: { id: action_close, optional: true } +- extendedWaitUntil: { visible: { id: scanner_screen }, timeout: 8000 } +- tapOn: { id: menu_button } +- extendedWaitUntil: { visible: { id: menu_screen }, timeout: 8000 } +- tapOn: "My Account" +- extendedWaitUntil: { visible: { id: my_account_screen }, timeout: 8000 } +- tapOn: "Blocked" +- extendedWaitUntil: { visible: { id: blocklist_screen }, timeout: 8000 } +- assertVisible: "Brandon McAnsh" + +# Unblock to restore the account to a clean state. +- tapOn: "Brandon McAnsh" +- assertVisible: "Unblock Brandon McAnsh?" +- tapOn: { text: "^Unblock$" } +- assertVisible: "No One Blocked" diff --git a/maestro/buy.yaml b/maestro/buy.yaml new file mode 100644 index 0000000000..eccd7db469 --- /dev/null +++ b/maestro/buy.yaml @@ -0,0 +1,26 @@ +appId: com.flipcash.app.android +name: "Buy — token info → confirm purchase (fund-safe)" +tags: + - smoke + - swap +--- +# Fund-safe: drives the buy flow to the confirmation screen and stops (never confirms). +- runFlow: subflows/login_with_deeplink.yaml +- runFlow: subflows/navigate_to_wallet.yaml + +- tapOn: "Float" +- extendedWaitUntil: { visible: { id: token_info_screen }, timeout: 8000 } +- tapOn: "Buy" +- extendedWaitUntil: { visible: { id: swap_screen }, timeout: 8000 } +- assertVisible: "Amount to Buy" + +# Minimal amount, then pick a payment currency. +- tapOn: { id: keypad_1 } +- tapOn: "Next" +- extendedWaitUntil: { visible: { id: token_selection_screen }, timeout: 8000 } +- assertVisible: "Select Payment Currency" +- tapOn: "USDF" + +# Confirmation — assert and stop (do not confirm the purchase). +- extendedWaitUntil: { visible: { id: buy_receipt_screen }, timeout: 8000 } +- assertVisible: "Confirm Purchase" diff --git a/maestro/claim_cashlink.yaml b/maestro/claim_cashlink.yaml index 4611c5bc4b..0911cbddee 100644 --- a/maestro/claim_cashlink.yaml +++ b/maestro/claim_cashlink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android onFlowStart: - runScript: scripts/extract_url.js --- diff --git a/maestro/coinbase_onramp.yaml b/maestro/coinbase_onramp.yaml new file mode 100644 index 0000000000..56386174fb --- /dev/null +++ b/maestro/coinbase_onramp.yaml @@ -0,0 +1,23 @@ +appId: com.flipcash.app.android +name: "Coinbase onramp — method present, enters onramp (sandbox)" +tags: + - deposit + - coinbase +--- +# Sandbox flag on so a follow-up can drive a test purchase without real funds. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "coinbase_onramp_sandbox_enabled" + +- runFlow: subflows/navigate_to_menu.yaml +- tapOn: "Add Money" +- extendedWaitUntil: { visible: { text: "Select Method" }, timeout: 8000 } + +# The Coinbase / Google Pay method is icon-only, so it's anchored by testTag. +- assertVisible: { id: purchase_method_coinbase } +- tapOn: { id: purchase_method_coinbase } + +# Coinbase requires a verified phone, so the onramp begins with phone verification. +- extendedWaitUntil: { visible: { id: verification_screen }, timeout: 8000 } +- assertVisible: "Connect Phone Number" diff --git a/maestro/currency_creator.yaml b/maestro/currency_creator.yaml new file mode 100644 index 0000000000..2184750940 --- /dev/null +++ b/maestro/currency_creator.yaml @@ -0,0 +1,19 @@ +appId: com.flipcash.app.android +name: "Currency Creator — intro & balance gate" +tags: + - smoke + - currency-creator +--- +# Fund-safe: opens the creator and confirms the $20-fee balance gate. Never pays. +- runFlow: subflows/login_with_deeplink.yaml + +- tapOn: "Discover" +- extendedWaitUntil: { visible: { id: discovery_screen }, timeout: 8000 } +- tapOn: "Create Your Own Currency" +- extendedWaitUntil: { visible: { id: currency_creator_screen }, timeout: 8000 } +- assertVisible: "Create Your Currency" + +# The $20 creation fee exceeds the test account's giveable balance, so Get Started +# surfaces the add-money gate. +- tapOn: "Get Started" +- assertVisible: "Add More Money" diff --git a/maestro/deposit.yaml b/maestro/deposit.yaml new file mode 100644 index 0000000000..b0e3a397a4 --- /dev/null +++ b/maestro/deposit.yaml @@ -0,0 +1,21 @@ +appId: com.flipcash.app.android +name: "Deposit (Add Money) → USDC" +tags: + - smoke + - deposit +--- +# Fund-safe: opens the Add Money method sheet and enters the USDC deposit flow. +- runFlow: subflows/login_with_deeplink.yaml +- runFlow: subflows/navigate_to_menu.yaml + +- tapOn: "Add Money" +- extendedWaitUntil: + visible: + text: "Select Method" + timeout: 8000 +- tapOn: "Other Wallet" +- extendedWaitUntil: + visible: + id: deposit_screen + timeout: 8000 +- assertVisible: "Deposit USDC" diff --git a/maestro/direct_send.yaml b/maestro/direct_send.yaml new file mode 100644 index 0000000000..de7a1abecd --- /dev/null +++ b/maestro/direct_send.yaml @@ -0,0 +1,29 @@ +appId: com.flipcash.app.android +name: "Direct Send — entry & phone gate" +tags: + - smoke + - payments +--- +# Deterministic clean login → home. +- runFlow: subflows/login_with_deeplink.yaml + +# Open the send flow from the scanner nav bar. +- tapOn: "Send" +- extendedWaitUntil: + visible: + id: send_screen + timeout: 8000 + +# This test account has no phone linked for send, so the flow opens on the phone +# gate (SendStep.PhoneGate). phone_gate_screen is auto-tagged from the step name. +- assertVisible: + id: phone_gate_screen +- assertVisible: "Send Money To Your Friends" + +# Close the sheet and return home. +- tapOn: + id: action_close +- extendedWaitUntil: + visible: + id: scanner_screen + timeout: 8000 diff --git a/maestro/discovery_leaderboard.yaml b/maestro/discovery_leaderboard.yaml new file mode 100644 index 0000000000..ebdc091c3b --- /dev/null +++ b/maestro/discovery_leaderboard.yaml @@ -0,0 +1,28 @@ +appId: com.flipcash.app.android +name: "Token Discovery — leaderboard → token info" +tags: + - smoke + - tokens +--- +# Deterministic clean login → home. +- runFlow: subflows/login_with_deeplink.yaml + +# Open Discover and assert the leaderboard rendered. +- tapOn: "Discover" +- extendedWaitUntil: + visible: + id: discovery_screen + timeout: 8000 +- assertVisible: + id: discovery_leaderboard +- assertVisible: + id: leaderboard_token_row + +# Drill into the top token's info screen. +- tapOn: + id: leaderboard_token_row + index: 0 +- extendedWaitUntil: + visible: + id: token_info_screen + timeout: 8000 diff --git a/maestro/helpers/close_open_sheet.yaml b/maestro/helpers/close_open_sheet.yaml index ef85e08d7f..00c9579f54 100644 --- a/maestro/helpers/close_open_sheet.yaml +++ b/maestro/helpers/close_open_sheet.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - swipe: direction: DOWN diff --git a/maestro/helpers/launch_app.yaml b/maestro/helpers/launch_app.yaml index 55a7ff80b0..5a2c929ce0 100644 --- a/maestro/helpers/launch_app.yaml +++ b/maestro/helpers/launch_app.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: when: diff --git a/maestro/helpers/launch_deeplink.yaml b/maestro/helpers/launch_deeplink.yaml index f07a8198ce..4260e89768 100644 --- a/maestro/helpers/launch_deeplink.yaml +++ b/maestro/helpers/launch_deeplink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- # Conditionally clear state (launchApp + stopApp brings app out of # Android's "stopped" state so the subsequent openLink can reach it) diff --git a/maestro/helpers/open_link_in_browser.yaml b/maestro/helpers/open_link_in_browser.yaml index 36f92cf5f3..cd6e48c5cc 100644 --- a/maestro/helpers/open_link_in_browser.yaml +++ b/maestro/helpers/open_link_in_browser.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android # helpers/open_link_in_browser.yaml # Opens a URL from clipboard in Chrome (or another app) diff --git a/maestro/helpers/screenshot.yaml b/maestro/helpers/screenshot.yaml index 053ad157b0..5f0c872117 100644 --- a/maestro/helpers/screenshot.yaml +++ b/maestro/helpers/screenshot.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - waitForAnimationToEnd - takeScreenshot: ${SCREENSHOT_NAME} diff --git a/maestro/login.yaml b/maestro/login.yaml index b2032120c1..8829f04a9f 100644 --- a/maestro/login.yaml +++ b/maestro/login.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: helpers/launch_app.yaml - runFlow: subflows/login.yaml diff --git a/maestro/login_logout.yaml b/maestro/login_logout.yaml index ba3b0b32c4..f4685f8bd6 100644 --- a/maestro/login_logout.yaml +++ b/maestro/login_logout.yaml @@ -1,5 +1,13 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android +name: "Login (seed) + Logout" +tags: + - smoke + - onboarding --- -- runFlow: helpers/launch_app.yaml +# Start logged-out so the seed-login UI is exercised for real. +- runFlow: + file: helpers/launch_app.yaml + env: + clearAppState: "true" - runFlow: subflows/login.yaml -- runFlow: subflows/logout.yaml \ No newline at end of file +- runFlow: subflows/logout.yaml diff --git a/maestro/open_token_info_deeplink.yaml b/maestro/open_token_info_deeplink.yaml index e8f473d870..c47537f171 100644 --- a/maestro/open_token_info_deeplink.yaml +++ b/maestro/open_token_info_deeplink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: subflows/login_with_deeplink.yaml - runFlow: diff --git a/maestro/run.sh b/maestro/run.sh new file mode 100755 index 0000000000..447cfab7b1 --- /dev/null +++ b/maestro/run.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Convenience runner for the Maestro E2E suite (local and CI). +# +# Usage: +# maestro/run.sh [more flows...] # run specific flows (local dev) +# maestro/run.sh --tags smoke # run by tag, JUnit output (CI) +# +# Handles the fiddly setup a fresh emulator/install needs: +# - loads creds from maestro/.env when present; existing env vars win (CI supplies them) +# - approves App Links so https deeplinks route to the app instead of the browser +# - seeds the send-to-contact recipient into the emulator's contacts (idempotent) +# - targets a specific device when several are attached (DEVICE env, default emulator-5554) +# +# Prereqs (see maestro/README.md): emulator booted, debug app installed +# (./gradlew :apps:flipcash:app:installDebug), maestro CLI on PATH. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_ID="com.flipcash.app.android" +DEVICE="${DEVICE:-emulator-5554}" +ENV_FILE="$SCRIPT_DIR/.env" + +# Value from the current environment (CI secrets) if set, else from maestro/.env. +cred() { + local name="$1" current="${!1:-}" + if [[ -n "$current" ]]; then printf '%s' "$current"; return; fi + [[ -f "$ENV_FILE" ]] && grep "^${name}=" "$ENV_FILE" | cut -d= -f2- || true +} +SEED_PHRASE="$(cred SEED_PHRASE)" +LOGIN_DEEPLINK="$(cred LOGIN_DEEPLINK)" +TIPCARD_DEEPLINK="$(cred TIPCARD_DEEPLINK)" +# Dedicated USDF-only (reserves-only) account for gate tests. +USDF_ONLY_DEEPLINK="$(cred USDF_ONLY_DEEPLINK)" +# On-Flipcash contact for send-to-contact tests (seeded into the emulator's contacts). +CONTACT_NAME="$(cred CONTACT_NAME)" +CONTACT_PHONE="$(cred CONTACT_PHONE)" + +# App Links verification does not survive a fresh install; approve so +# https://app.flipcash.com/... deeplinks open the app, not Chrome. +adb -s "$DEVICE" shell pm set-app-links --package "$APP_ID" 2 all >/dev/null 2>&1 || true + +# Seed the send-to-contact recipient into the emulator's contacts (idempotent). No-op +# unless CONTACT_NAME + CONTACT_PHONE are set. The device-side single quotes preserve +# spaces in the name; the new raw contact is the highest auto-increment _id. +seed_contact() { + [[ -z "$CONTACT_NAME" || -z "$CONTACT_PHONE" ]] && return 0 + local data_uri="content://com.android.contacts/data" + local raw_uri="content://com.android.contacts/raw_contacts" + if adb -s "$DEVICE" shell content query --uri "$data_uri" --projection mimetype:data1 2>/dev/null \ + | grep -q "$CONTACT_PHONE"; then + return 0 # already seeded + fi + adb -s "$DEVICE" shell content insert --uri "$raw_uri" \ + --bind account_name:s: --bind account_type:s: >/dev/null 2>&1 + local rid + rid=$(adb -s "$DEVICE" shell content query --uri "$raw_uri" --projection _id 2>/dev/null \ + | grep -oE '_id=[0-9]+' | cut -d= -f2 | sort -n | tail -1) + [[ -z "$rid" ]] && { echo "warning: could not seed contact" >&2; return 0; } + adb -s "$DEVICE" shell "content insert --uri $data_uri --bind raw_contact_id:i:$rid \ + --bind mimetype:s:vnd.android.cursor.item/name --bind data1:s:'$CONTACT_NAME'" >/dev/null 2>&1 + adb -s "$DEVICE" shell "content insert --uri $data_uri --bind raw_contact_id:i:$rid \ + --bind mimetype:s:vnd.android.cursor.item/phone_v2 --bind data1:s:'$CONTACT_PHONE' \ + --bind data2:i:2" >/dev/null 2>&1 +} +seed_contact + +# Build the maestro target: `--tags ` runs the whole suite filtered by tag with +# JUnit output (CI); otherwise the args are treated as specific flow files (local dev). +if [[ "${1:-}" == "--tags" ]]; then + shift + include="${1:-smoke}" + target=( --include-tags "$include" + --exclude-tags "${MAESTRO_EXCLUDE_TAGS:-spends-funds,creates-account}" + --format junit --output "${MAESTRO_OUTPUT:-maestro-report.xml}" + "$SCRIPT_DIR" ) +elif [[ $# -eq 0 ]]; then + echo "usage: $0 [more flows...] | $0 --tags " >&2 + exit 1 +else + target=( "$@" ) +fi + +maestro --device "$DEVICE" test \ + -e SEED_PHRASE="$SEED_PHRASE" \ + -e LOGIN_DEEPLINK="$LOGIN_DEEPLINK" \ + -e TIPCARD_DEEPLINK="$TIPCARD_DEEPLINK" \ + -e USDF_ONLY_DEEPLINK="$USDF_ONLY_DEEPLINK" \ + -e CONTACT_NAME="$CONTACT_NAME" \ + -e CONTACT_PHONE="$CONTACT_PHONE" \ + -e BETA_FLAGS="${BETA_FLAGS:-}" \ + "${target[@]}" diff --git a/maestro/screenshots/capture_all.yaml b/maestro/screenshots/capture_all.yaml index ab2462124e..62d1ba586a 100644 --- a/maestro/screenshots/capture_all.yaml +++ b/maestro/screenshots/capture_all.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: All" tags: - screenshot diff --git a/maestro/screenshots/groups/01_pre_login.yaml b/maestro/screenshots/groups/01_pre_login.yaml index d2de76078d..bf05db2a84 100644 --- a/maestro/screenshots/groups/01_pre_login.yaml +++ b/maestro/screenshots/groups/01_pre_login.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Pre-Login" tags: - screenshot diff --git a/maestro/screenshots/groups/02_home.yaml b/maestro/screenshots/groups/02_home.yaml index 0564cc35b6..8bedb84452 100644 --- a/maestro/screenshots/groups/02_home.yaml +++ b/maestro/screenshots/groups/02_home.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Home" tags: - screenshot diff --git a/maestro/screenshots/groups/03_menu.yaml b/maestro/screenshots/groups/03_menu.yaml index 651aaf2bfe..df022a19cc 100644 --- a/maestro/screenshots/groups/03_menu.yaml +++ b/maestro/screenshots/groups/03_menu.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Menu" tags: - screenshot diff --git a/maestro/screenshots/groups/04_account.yaml b/maestro/screenshots/groups/04_account.yaml index fdeceecce7..9821db58b7 100644 --- a/maestro/screenshots/groups/04_account.yaml +++ b/maestro/screenshots/groups/04_account.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Account & Settings" tags: - screenshot diff --git a/maestro/screenshots/groups/05_features.yaml b/maestro/screenshots/groups/05_features.yaml index 7bd6ffa7d9..efd835a016 100644 --- a/maestro/screenshots/groups/05_features.yaml +++ b/maestro/screenshots/groups/05_features.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Advanced Features" tags: - screenshot diff --git a/maestro/screenshots/groups/06_give.yaml b/maestro/screenshots/groups/06_give.yaml index ab5ec0eb90..b3e2be18eb 100644 --- a/maestro/screenshots/groups/06_give.yaml +++ b/maestro/screenshots/groups/06_give.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Give Flow" tags: - screenshot diff --git a/maestro/screenshots/groups/07_tokens.yaml b/maestro/screenshots/groups/07_tokens.yaml index d1fa9d629f..3d6262bff4 100644 --- a/maestro/screenshots/groups/07_tokens.yaml +++ b/maestro/screenshots/groups/07_tokens.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Tokens" tags: - screenshot diff --git a/maestro/screenshots/groups/08_withdraw.yaml b/maestro/screenshots/groups/08_withdraw.yaml index 0deaac43c9..c4405f4c74 100644 --- a/maestro/screenshots/groups/08_withdraw.yaml +++ b/maestro/screenshots/groups/08_withdraw.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Withdraw Flow" tags: - screenshot diff --git a/maestro/screenshots/groups/09_history.yaml b/maestro/screenshots/groups/09_history.yaml index 5ee8d78dbb..16b830a56d 100644 --- a/maestro/screenshots/groups/09_history.yaml +++ b/maestro/screenshots/groups/09_history.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android name: "Screenshots: Transaction History" tags: - screenshot diff --git a/maestro/sell.yaml b/maestro/sell.yaml new file mode 100644 index 0000000000..7eb3fbfcea --- /dev/null +++ b/maestro/sell.yaml @@ -0,0 +1,17 @@ +appId: com.flipcash.app.android +name: "Sell — token info → amount entry (fund-safe)" +tags: + - smoke + - swap +--- +# Fund-safe: reaches the sell amount-entry. Reaching the sell confirmation depends on +# the held balance clearing the minimum + fees, so it's left for a funded account. +- runFlow: subflows/login_with_deeplink.yaml +- runFlow: subflows/navigate_to_wallet.yaml + +- tapOn: "Float" +- extendedWaitUntil: { visible: { id: token_info_screen }, timeout: 8000 } +- tapOn: "Sell" +- extendedWaitUntil: { visible: { id: swap_screen }, timeout: 8000 } +- assertVisible: "Amount to Sell" +- assertVisible: { id: keypad_5 } diff --git a/maestro/send_to_contact.yaml b/maestro/send_to_contact.yaml new file mode 100644 index 0000000000..863f0a4192 --- /dev/null +++ b/maestro/send_to_contact.yaml @@ -0,0 +1,28 @@ +appId: com.flipcash.app.android +name: "Send to a Flipcash contact" +tags: + - payments +--- +# Send cash to an on-Flipcash contact, then land in the conversation. Mirrors iOS +# SendSmokeTests (which sends to a fixed contact, "Raul Riera"). +# +# Requires (see README "Two phone-verification paths"): +# - a send-enabled account: a phone linked via the emulator's real number + real SMS +# (`adb emu sms send`), not the onboarding test number — else the flow stops at the phone gate; +# - the CONTACT_NAME / CONTACT_PHONE contact (a real Flipcash user) in the emulator's contacts — +# the runner seeds this automatically. +# Until the account is phone-linked this is a scaffold — the steps encode the expected journey. +- runFlow: subflows/login_with_deeplink.yaml + +- tapOn: "Send" +- extendedWaitUntil: { visible: { id: send_screen }, timeout: 8000 } + +# With a linked phone the send flow shows the contact list (not the phone gate). +- extendedWaitUntil: { visible: { id: send_contact_list }, timeout: 8000 } +- tapOn: { id: send_search_field } +- inputText: ${CONTACT_NAME} +- extendedWaitUntil: { visible: { id: send_contact_row }, timeout: 8000 } +- tapOn: { id: send_contact_row, index: 0 } + +# Opening a Flipcash contact lands in the conversation (from which cash/amount is entered). +- extendedWaitUntil: { visible: { id: chat_screen }, timeout: 8000 } diff --git a/maestro/show_bill_and_put_back_in_wallet.yaml b/maestro/show_bill_and_put_back_in_wallet.yaml index 1c5bdc2271..83585764db 100644 --- a/maestro/show_bill_and_put_back_in_wallet.yaml +++ b/maestro/show_bill_and_put_back_in_wallet.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: subflows/login_with_deeplink.yaml - runFlow: subflows/pull_out_bill.yaml diff --git a/maestro/subflows/collect_own_cashlink.yaml b/maestro/subflows/collect_own_cashlink.yaml index e5bd7c13ca..27f7adf1f2 100644 --- a/maestro/subflows/collect_own_cashlink.yaml +++ b/maestro/subflows/collect_own_cashlink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - assertVisible: Collect - tapOn: Collect diff --git a/maestro/subflows/create_account.yaml b/maestro/subflows/create_account.yaml new file mode 100644 index 0000000000..0fb8912d9a --- /dev/null +++ b/maestro/subflows/create_account.yaml @@ -0,0 +1,33 @@ +# Create a fresh account through onboarding, for flows that must run on a brand-new +# account (e.g. one-run-per-account setup). Uses the test phone +1 (500) 555-0000 and +# the all-zero OTP. Enables beta flags at launch via BETA_FLAGS (comma-separated +# FeatureFlag.key list; debug builds only). +appId: com.flipcash.app.android +--- +- clearState +- launchApp: + arguments: + isUiTest: true + betaFlags: ${BETA_FLAGS} + +- extendedWaitUntil: { visible: { id: login_screen }, timeout: 15000 } +- tapOn: { id: create_account_button } + +# Phone verification (test number + all-zero OTP). +- extendedWaitUntil: { visible: { id: phone_entry_screen }, timeout: 15000 } +- inputText: "5005550000" +- tapOn: "Next" +- extendedWaitUntil: { visible: { id: phone_code_screen }, timeout: 15000 } +- inputText: "000000" + +# Access key — take the "wrote it down" path (confirm the dialog). +- extendedWaitUntil: { visible: { id: access_key_screen }, timeout: 15000 } +- tapOn: "Wrote the 12 Words Down Instead?" +- tapOn: "Wrote the 12 Words Down Instead?" + +# A push-notification permission dialog appears only if it isn't already granted. +- tapOn: + text: "Allow" + optional: true + +- extendedWaitUntil: { visible: { id: scanner_screen }, timeout: 20000 } diff --git a/maestro/subflows/login.yaml b/maestro/subflows/login.yaml index f64efa9487..ee347cd0b5 100644 --- a/maestro/subflows/login.yaml +++ b/maestro/subflows/login.yaml @@ -1,5 +1,5 @@ # Login -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - tapOn: Log in - extendedWaitUntil: diff --git a/maestro/subflows/login_usdf_only.yaml b/maestro/subflows/login_usdf_only.yaml new file mode 100644 index 0000000000..3973cdbf04 --- /dev/null +++ b/maestro/subflows/login_usdf_only.yaml @@ -0,0 +1,14 @@ +# Deeplink login into the dedicated USDF-only test account (reserves only, no community +# currency) used for gate tests. Requires env: USDF_ONLY_DEEPLINK. +appId: com.flipcash.app.android +--- +- stopApp +- runFlow: + file: ../helpers/launch_deeplink.yaml + env: + clearAppState: "true" + deeplink: ${USDF_ONLY_DEEPLINK} + +- extendedWaitUntil: + visible: + id: "scanner_screen" diff --git a/maestro/subflows/login_with_deeplink.yaml b/maestro/subflows/login_with_deeplink.yaml index 5391a93435..919ddb2c9b 100644 --- a/maestro/subflows/login_with_deeplink.yaml +++ b/maestro/subflows/login_with_deeplink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - stopApp - runFlow: diff --git a/maestro/subflows/login_with_flags.yaml b/maestro/subflows/login_with_flags.yaml new file mode 100644 index 0000000000..c9f9083916 --- /dev/null +++ b/maestro/subflows/login_with_flags.yaml @@ -0,0 +1,11 @@ +# Seed login that also enables beta flags via a launch argument (debug builds only). +# Seed login stays in one process (no relaunch), so flags applied in onCreate persist. +# Requires env: SEED_PHRASE and BETA_FLAGS (comma-separated FeatureFlag.key list). +appId: com.flipcash.app.android +--- +- clearState +- launchApp: + arguments: + isUiTest: true + betaFlags: ${BETA_FLAGS} +- runFlow: login.yaml diff --git a/maestro/subflows/logout.yaml b/maestro/subflows/logout.yaml index b502e95b43..13b071adac 100644 --- a/maestro/subflows/logout.yaml +++ b/maestro/subflows/logout.yaml @@ -1,16 +1,7 @@ -# Reusable Logout flow -appId: com.flipcash.app.android.dev +# Reusable Logout flow. Log Out lives on the My Account screen. +appId: com.flipcash.app.android --- -- tapOn: - id: scanner_view -- extendedWaitUntil: - visible: - id: scanner_screen -- tapOn: - id: menu_button -- extendedWaitUntil: - visible: - id: menu_screen +- runFlow: navigate_to_my_account.yaml - tapOn: Log Out - assertVisible: Log Out - assertVisible: Cancel diff --git a/maestro/subflows/navigate_to_advanced_features.yaml b/maestro/subflows/navigate_to_advanced_features.yaml index f664f32a96..d9c295af9b 100644 --- a/maestro/subflows/navigate_to_advanced_features.yaml +++ b/maestro/subflows/navigate_to_advanced_features.yaml @@ -1,7 +1,7 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: navigate_to_menu.yaml -- tapOn: Advanced Features +- tapOn: Advanced - extendedWaitUntil: visible: id: advanced_features_screen diff --git a/maestro/subflows/navigate_to_app_settings.yaml b/maestro/subflows/navigate_to_app_settings.yaml index a329ee6b5e..c8be089652 100644 --- a/maestro/subflows/navigate_to_app_settings.yaml +++ b/maestro/subflows/navigate_to_app_settings.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: navigate_to_menu.yaml - tapOn: App Settings diff --git a/maestro/subflows/navigate_to_give.yaml b/maestro/subflows/navigate_to_give.yaml index df12740631..148d3d452d 100644 --- a/maestro/subflows/navigate_to_give.yaml +++ b/maestro/subflows/navigate_to_give.yaml @@ -1,7 +1,7 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml -- tapOn: Give +- tapOn: Cash - extendedWaitUntil: visible: id: cash_screen diff --git a/maestro/subflows/navigate_to_menu.yaml b/maestro/subflows/navigate_to_menu.yaml index d5015e75a3..6d5fcf4805 100644 --- a/maestro/subflows/navigate_to_menu.yaml +++ b/maestro/subflows/navigate_to_menu.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml - tapOn: diff --git a/maestro/subflows/navigate_to_my_account.yaml b/maestro/subflows/navigate_to_my_account.yaml index 3574ae3791..6d2a927bdb 100644 --- a/maestro/subflows/navigate_to_my_account.yaml +++ b/maestro/subflows/navigate_to_my_account.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: navigate_to_menu.yaml - tapOn: My Account diff --git a/maestro/subflows/navigate_to_wallet.yaml b/maestro/subflows/navigate_to_wallet.yaml index 82d0ee77eb..a89fbd9390 100644 --- a/maestro/subflows/navigate_to_wallet.yaml +++ b/maestro/subflows/navigate_to_wallet.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml - tapOn: Wallet diff --git a/maestro/subflows/navigate_to_withdraw.yaml b/maestro/subflows/navigate_to_withdraw.yaml index c042da97c0..08924e9b36 100644 --- a/maestro/subflows/navigate_to_withdraw.yaml +++ b/maestro/subflows/navigate_to_withdraw.yaml @@ -1,11 +1,15 @@ -appId: com.flipcash.app.android.dev +# Menu → Withdraw Money → (USDC path) → amount entry. +appId: com.flipcash.app.android --- - runFlow: navigate_to_menu.yaml -- tapOn: Withdraw Funds +- tapOn: "Withdraw Money" - extendedWaitUntil: visible: - text: Select Currency -- tapOn: Float + id: withdrawal_screen + timeout: 8000 +- tapOn: "Withdraw as USDC" +- tapOn: "Next" - extendedWaitUntil: visible: id: withdraw_entry_screen + timeout: 8000 diff --git a/maestro/subflows/open_token_info.yaml b/maestro/subflows/open_token_info.yaml index a847a978b6..6b3cb92f7f 100644 --- a/maestro/subflows/open_token_info.yaml +++ b/maestro/subflows/open_token_info.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml - tapOn: Wallet diff --git a/maestro/subflows/pull_out_bill.yaml b/maestro/subflows/pull_out_bill.yaml index 6bcaf2d4e9..d2634eac3c 100644 --- a/maestro/subflows/pull_out_bill.yaml +++ b/maestro/subflows/pull_out_bill.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml - tapOn: Give diff --git a/maestro/subflows/return_to_scanner.yaml b/maestro/subflows/return_to_scanner.yaml index 722362c06d..f8e8d7c932 100644 --- a/maestro/subflows/return_to_scanner.yaml +++ b/maestro/subflows/return_to_scanner.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - back - runFlow: ../helpers/close_open_sheet.yaml diff --git a/maestro/subflows/share_cashlink.yaml b/maestro/subflows/share_cashlink.yaml index 17ea302255..0efed52fa6 100644 --- a/maestro/subflows/share_cashlink.yaml +++ b/maestro/subflows/share_cashlink.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - tapOn: Send as a Link # we need maestro to gain access to the content as well diff --git a/maestro/tip_chat.yaml b/maestro/tip_chat.yaml new file mode 100644 index 0000000000..75a6ba06fd --- /dev/null +++ b/maestro/tip_chat.yaml @@ -0,0 +1,28 @@ +appId: com.flipcash.app.android +name: "Tip chat — open conversation & send a message" +tags: + - tipping +--- +# Seed-login into the test account (which has tips set up + a tip chat) with the +# tipping beta flag enabled at launch. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "tipping_enabled" + +# Tips tab lists tip conversations; open the first one. +- tapOn: "Tips" +- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } +- assertVisible: { id: send_contact_row } +- tapOn: { id: send_contact_row, index: 0 } + +# In the tip conversation: the tip event and the message composer are present. +- extendedWaitUntil: { visible: { id: chat_screen }, timeout: 8000 } +- assertVisible: { id: chat_message_input } +- assertVisible: "You tipped" + +# Send a message and confirm it lands in the transcript. +- tapOn: { id: chat_message_input } +- inputText: "gg" +- tapOn: { id: chat_send_icon } +- extendedWaitUntil: { visible: { text: "gg" }, timeout: 8000 } diff --git a/maestro/tip_deeplink.yaml b/maestro/tip_deeplink.yaml new file mode 100644 index 0000000000..799c4ca5dd --- /dev/null +++ b/maestro/tip_deeplink.yaml @@ -0,0 +1,23 @@ +appId: com.flipcash.app.android +name: "Tip deeplink — opens the tip flow" +tags: + - tipping +--- +# Seed-login with tipping enabled. A fresh login clears local state, so give balances +# a moment to sync (opening the Wallet forces the token balances to load) before opening +# the deeplink — otherwise hasGiveableBalance is transiently false and the app shows the +# add-money/discover gate instead of the tip flow. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "tipping_enabled" +- runFlow: subflows/navigate_to_wallet.yaml +- assertVisible: { id: wallet_screen } + +# Open the tip card deeplink -> presents the tip flow for that recipient. +- openLink: ${TIPCARD_DEEPLINK} +- extendedWaitUntil: + visible: + text: "Swipe to Tip" + timeout: 12000 +- assertVisible: "Tip Brandon McAnsh" diff --git a/maestro/tipping_setup.yaml b/maestro/tipping_setup.yaml new file mode 100644 index 0000000000..24fc3d1351 --- /dev/null +++ b/maestro/tipping_setup.yaml @@ -0,0 +1,29 @@ +appId: com.flipcash.app.android +name: "Tipping — create account & set up tip card" +tags: + - tipping + - creates-account +--- +# Tip setup is one-run-per-account, so start from a brand-new account with the tipping +# beta flag enabled at launch (no Labs-UI toggling). +- runFlow: + file: subflows/create_account.yaml + env: + BETA_FLAGS: "tipping_enabled" + +# Tipping is enabled -> the Tips tab is present. +- assertVisible: "Tips" +- tapOn: "Tips" +- extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } + +# Set up the tip card: name is the next step. +- tapOn: "Start Receiving Tips" +- extendedWaitUntil: { visible: { id: name_screen }, timeout: 8000 } +- tapOn: { text: "Your Name" } +- inputText: "Test Tipper" +- tapOn: "Next" + +# The tip card renders with the chosen name. +- extendedWaitUntil: { visible: { id: tip_card_screen }, timeout: 8000 } +- assertVisible: "My Tip Card" +- assertVisible: "Tip Test Tipper" diff --git a/maestro/usdf_only_gate.yaml b/maestro/usdf_only_gate.yaml new file mode 100644 index 0000000000..bfcc883f68 --- /dev/null +++ b/maestro/usdf_only_gate.yaml @@ -0,0 +1,19 @@ +appId: com.flipcash.app.android +name: "Gate — USDF-only account: give routes to Discover" +tags: + - gate +--- +# Deterministic gate coverage on a reserves-only account (holds USDF, no community +# currency). Mirrors iOS GiveDiscoverGateRegressionTests: with a balance but nothing +# giveable, tapping Cash surfaces "No Community Currencies Yet" and routes to Discover. +# +# Requires env: USDF_ONLY_DEEPLINK (a provisioned USDF-only test account). Until that +# account exists this flow is a scaffold — the assertions encode the expected behaviour. +- runFlow: subflows/login_usdf_only.yaml + +- tapOn: "Cash" +- extendedWaitUntil: + visible: + text: "No Community Currencies Yet" + timeout: 8000 +- assertVisible: "Discover Currencies" diff --git a/maestro/view_token_info.yaml b/maestro/view_token_info.yaml index 4893059a98..76b39cd0e8 100644 --- a/maestro/view_token_info.yaml +++ b/maestro/view_token_info.yaml @@ -1,4 +1,4 @@ -appId: com.flipcash.app.android.dev +appId: com.flipcash.app.android --- - runFlow: subflows/login_with_deeplink.yaml - runFlow: subflows/open_token_info.yaml diff --git a/maestro/wallet_token_info.yaml b/maestro/wallet_token_info.yaml new file mode 100644 index 0000000000..96b4c5d9b0 --- /dev/null +++ b/maestro/wallet_token_info.yaml @@ -0,0 +1,20 @@ +appId: com.flipcash.app.android +name: "Wallet → Token Info" +tags: + - smoke + - tokens +--- +# Deterministic clean login → home. +- runFlow: subflows/login_with_deeplink.yaml + +# Open the wallet and drill into a held token's info screen. +- runFlow: subflows/navigate_to_wallet.yaml +- tapOn: "Float" +- extendedWaitUntil: + visible: + id: token_info_screen + timeout: 10000 + +# The market-cap chart is always present on token info. +- assertVisible: + id: market_cap_chart diff --git a/maestro/withdraw.yaml b/maestro/withdraw.yaml new file mode 100644 index 0000000000..cef70dc16d --- /dev/null +++ b/maestro/withdraw.yaml @@ -0,0 +1,17 @@ +appId: com.flipcash.app.android +name: "Withdraw — reach amount entry" +tags: + - smoke + - withdraw +--- +# Fund-safe: drives the withdrawal wizard to the amount-entry screen. Reaching the +# destination/confirmation steps needs a funded reserves balance, so those steps +# (withdraw_destination_screen / withdraw_confirmation_screen, tagged in the app) are +# left for a funded account. +- runFlow: subflows/login_with_deeplink.yaml +- runFlow: subflows/navigate_to_withdraw.yaml + +- assertVisible: + id: withdraw_entry_screen +- assertVisible: + id: keypad_5 diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index fe3f308f2d..b9253d4a11 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Color @@ -306,6 +307,9 @@ fun BottomBarView( CodeButton( modifier = Modifier .fillMaxWidth() + .addIf(action.testTag != null) { + Modifier.testTag(action.testTag!!) + } .addIf(index == actions.lastIndex) { Modifier.padding( bottom = when (action.style) { diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index bbbc789714..3113fddf4d 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -1,7 +1,10 @@ package com.getcode.navigation import android.os.Parcelable +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.getcode.navigation.results.NavResultKey @@ -20,11 +23,35 @@ enum class NavMetadataKeys(val key: String, ) { /** * DSL helper: registers an entry whose metadata is derived from [T]'s marker interfaces. + * + * Every destination is wrapped in a [Box] tagged with a stable screen-root id so the whole + * screen is addressable as a single resource-id in UI tests (Maestro / UiAutomator, via + * `testTagsAsResourceId`). The tag defaults to one derived from the route type name + * ([screenRootTag], e.g. `AppRoute.Menu.MyAccount` → `my_account_screen`); pass an explicit + * [testTag] only when a route needs an id that differs from its type name. + * + * Keeping the tag here — at the one place every route is registered — means screen-root + * test anchors live in a single file and can't drift out of sync with the screens. */ inline fun EntryProviderScope.annotatedEntry( + testTag: String? = null, noinline content: @Composable (T) -> Unit ) { - entry(metadata = T::class.metadata(), content = content) + val resolvedTag = testTag ?: screenRootTag(T::class.simpleName) + val tagged: @Composable (T) -> Unit = { key -> Box(Modifier.testTag(resolvedTag)) { content(key) } } + entry(metadata = T::class.metadata(), content = tagged) +} + +/** + * Derives a screen-root test id from a route's simple type name: CamelCase becomes + * snake_case with a `_screen` suffix (e.g. `MyAccount` → `my_account_screen`, + * `Scanner` → `scanner_screen`). + */ +fun screenRootTag(simpleName: String?): String { + val base = (simpleName ?: "unknown") + .replace(Regex("([a-z0-9])([A-Z])"), "$1_$2") + .lowercase() + return "${base}_screen" } /**