diff --git a/.github/scripts/e2e.sh b/.github/scripts/e2e.sh index ad82b15456..2508c3080b 100644 --- a/.github/scripts/e2e.sh +++ b/.github/scripts/e2e.sh @@ -4,20 +4,13 @@ set -e PLATFORM=$1 # "ios" or "android" shift # Remove first argument so $@ contains only the additional options -# Require test credentials to be supplied via env (CI secrets or local .env.local). -# Never fall back to baked-in defaults. -if [ -z "${TEST_EMAIL:-}" ] || [ -z "${TEST_PASSWORD:-}" ]; then - echo "ERROR: TEST_EMAIL and TEST_PASSWORD must be set (via CI secrets or a gitignored .env.local)" >&2 - exit 1 -fi - # Generate unique ID for this test run UNIQUE_ID=$(date +%s) if [ "$PLATFORM" = "ios" ]; then START_DATE=$(date -j -v+7d +"%Y-%m-%d") END_DATE=$(date -j -v+14d +"%Y-%m-%d") - + TODAY_DATE=$(date -j +"%b %-d, %Y") # e.g. "Apr 16, 2026" get_month() { date -j -f "%Y-%m-%d" "$1" +"%B"; } get_day() { date -j -f "%Y-%m-%d" "$1" +"%-d"; } get_year() { date -j -f "%Y-%m-%d" "$1" +"%Y"; } @@ -25,9 +18,9 @@ if [ "$PLATFORM" = "ios" ]; then else START_DATE=$(date -d "+7 days" +"%Y-%m-%d") END_DATE=$(date -d "+14 days" +"%Y-%m-%d") - + TODAY_DATE=$(date +"%-d %b %Y") get_month() { date -d "$1" +"%B"; } - get_day() { date -d "$1" +"%-d"; } + get_day() { date -d "$1" +"%d"; } get_year() { date -d "$1" +"%Y"; } get_month_num() { date -d "$1" +"%-m"; } fi @@ -40,8 +33,8 @@ END_TAPS=$(( ($(get_year "$END_DATE") - CURRENT_YEAR) * 12 + ($(get_month_num "$ if [ "$PLATFORM" = "ios" ]; then maestro test --config .maestro/config.yaml "$@" \ - -e TEST_EMAIL="$TEST_EMAIL" \ - -e TEST_PASSWORD="$TEST_PASSWORD" \ + -e TEST_EMAIL="${TEST_EMAIL}" \ + -e TEST_PASSWORD="${TEST_PASSWORD}" \ -e TRIP_NAME="${TRIP_NAME:-E2E-Trip-$UNIQUE_ID}" \ -e PACK_NAME="${PACK_NAME:-E2E-Pack-$UNIQUE_ID}" \ -e APP_ID="${APP_ID:-com.andrewbierman.packrat.preview}" \ @@ -53,11 +46,12 @@ if [ "$PLATFORM" = "ios" ]; then -e END_MONTH="$(get_month "$END_DATE")" \ -e END_DAY="$(get_day "$END_DATE")" \ -e END_TAPS="$END_TAPS" \ + -e TODAY_DATE="$TODAY_DATE" \ .maestro/master-flow.yaml else maestro test --config .maestro/config-android.yaml "$@" \ - -e TEST_EMAIL="$TEST_EMAIL" \ - -e TEST_PASSWORD="$TEST_PASSWORD" \ + -e TEST_EMAIL="${TEST_EMAIL}" \ + -e TEST_PASSWORD="${TEST_PASSWORD}" \ -e TRIP_NAME="${TRIP_NAME:-E2E-Trip-$UNIQUE_ID}" \ -e PACK_NAME="${PACK_NAME:-E2E-Pack-$UNIQUE_ID}" \ -e APP_ID="${APP_ID:-com.packratai.mobile.preview}" \ diff --git a/.maestro/flows/auth/login-flow.yaml b/.maestro/flows/auth/login-flow.yaml index 9504a3712e..7f99fecdaa 100644 --- a/.maestro/flows/auth/login-flow.yaml +++ b/.maestro/flows/auth/login-flow.yaml @@ -28,10 +28,6 @@ appId: ${APP_ID} id: "sign-in-email-button" # Wait for login form to appear. -# auth/(login) is presented as a modal; waitForAnimationToEnd alone is not -# sufficient — the XCTest accessibility tree may not include the modal until -# the presentation animation fully settles. extendedWaitUntil polls until -# the field is accessible, giving the modal time to fully render. # iOS 26 / slow CI: the app startup takes ~10s before sign-in-button appears; # by the time the modal opens we may have little budget left. Use 35s here. - runFlow: @@ -44,10 +40,7 @@ appId: ${APP_ID} timeout: 35000 - waitForAnimationToEnd -# Fill in the email field. -# iOS: NativeWindUI's TextField inside accessible={false} FormSection doesn't expose -# testID to Maestro's XCTest snapshot, so we match the placeholder text instead. -# Android: testID maps reliably to resource-id. +# Fill in the email field - runFlow: when: platform: iOS @@ -89,8 +82,7 @@ appId: ${APP_ID} # Wait for navigation to complete — login can take a few seconds - waitForAnimationToEnd -# Handle transient network error: if the API call failed (e.g. "Network request -# failed" dialog), dismiss the alert and retry once with the same credentials. +# Handle transient network error: if the API call failed, dismiss the alert and retry once. - runFlow: when: visible: @@ -125,4 +117,4 @@ appId: ${APP_ID} - extendedWaitUntil: visible: text: "Packs" - timeout: 35000 + timeout: 35000 \ No newline at end of file diff --git a/.maestro/flows/auth/logout-flow.yaml b/.maestro/flows/auth/logout-flow.yaml index e3d32aa334..a744399462 100644 --- a/.maestro/flows/auth/logout-flow.yaml +++ b/.maestro/flows/auth/logout-flow.yaml @@ -9,53 +9,29 @@ appId: ${APP_ID} - waitForAnimationToEnd -# Scroll down to find the sign-out button. -# iOS: use testID (id:) to avoid UITextView accessibilityValue issues with text-based matching. -# Android: use text: which works reliably (UITextView issue is iOS-only). -- runFlow: - when: - platform: iOS - commands: - - scrollUntilVisible: - element: - id: "sign-out-button" - direction: DOWN - - tapOn: - id: "sign-out-button" -- runFlow: - when: - platform: Android - commands: - - scrollUntilVisible: - element: - text: "Log Out" - direction: DOWN - - tapOn: - text: "Log Out" +# Scroll down to find the log out button if needed +- scrollUntilVisible: + element: + text: "Log Out" + direction: DOWN + +# Tap Log Out +- tapOn: + text: "Log Out" - waitForAnimationToEnd -# Handle sync-conflict confirmation dialog if present (only appears when there are unsynced changes). -# Both platforms use native Alert.alert(). With snapshotKeyHonorModalViews:false the main-window -# elements appear first in the snapshot tree, so tapOn:text:"Log Out" (any index) hits the -# profile list button (resource-id=sign-out-button, y=706) instead of the alert action. -# Fix: use rightOf:text:"Cancel" to uniquely identify the dialog "Log Out" — "Cancel" only -# exists inside the dialog, so this selector picks the adjacent alert action button. -# waitForAnimationToEnd ensures the native alert is fully settled before we interact. - runFlow: when: visible: text: "Sync in progress" commands: - - waitForAnimationToEnd - tapOn: - text: "Log Out" - rightOf: - text: "Cancel" + text: "Proceed" - waitForAnimationToEnd -# Handle post-logout dialog if it appears - choose to Sign-in again +# Handle post-logout dialog - choose to Sign-in again - runFlow: when: visible: @@ -66,22 +42,6 @@ appId: ${APP_ID} - waitForAnimationToEnd -# Assert we are back on the auth screen (allow up to 30s for app reload). -# iOS: use testID to avoid UITextView text-matching issues on the Sign In button. -# Android: use text: which works reliably. -- runFlow: - when: - platform: iOS - commands: - - extendedWaitUntil: - visible: - id: "sign-in-email-button" - timeout: 30000 -- runFlow: - when: - platform: Android - commands: - - extendedWaitUntil: - visible: - text: "Sign In" - timeout: 30000 +# Assert we are back on the auth screen +- assertVisible: + text: "Sign In" diff --git a/.maestro/flows/catalog/catalog-browse-flow.yaml b/.maestro/flows/catalog/catalog-browse-flow.yaml index 3851dcf0d1..01886c127c 100644 --- a/.maestro/flows/catalog/catalog-browse-flow.yaml +++ b/.maestro/flows/catalog/catalog-browse-flow.yaml @@ -2,29 +2,21 @@ appId: ${APP_ID} --- # Catalog Browse Flow: Verify catalog tab loads items and categories - waitForAnimationToEnd - -# Navigate to Catalog tab - tapOn: text: "Catalog" - waitForAnimationToEnd - -# Assert catalog header loaded - assertVisible: text: "Catalog" - -# Wait for actual catalog items to load via testID. -# Previous text-based check (".*items.*") matched "0 items" during loading -# state without verifying data actually arrived. Use id: for reliability. -- extendedWaitUntil: - visible: - id: "catalog-item-card" - timeout: 30000 - -# Assert category filters render - assertVisible: text: "All" - -# Intentionally stay on Catalog tab. -# catalog-item-detail-flow runs next and starts with tapOn:"Catalog" which -# is a scroll-to-top on the already-active tab — items remain loaded and -# no fresh API call is needed. +- assertVisible: + text: ".*items.*" +- scrollUntilVisible: + element: + text: "Showing.*items" + direction: DOWN + timeout: 10000 + speed: 20 +- tapOn: + text: "Packs" +- waitForAnimationToEnd \ No newline at end of file diff --git a/.maestro/flows/catalog/catalog-item-detail-flow.yaml b/.maestro/flows/catalog/catalog-item-detail-flow.yaml index d0875016df..a2b71c5b9b 100644 --- a/.maestro/flows/catalog/catalog-item-detail-flow.yaml +++ b/.maestro/flows/catalog/catalog-item-detail-flow.yaml @@ -2,77 +2,43 @@ appId: ${APP_ID} --- # Catalog Item Detail Flow: Tap a catalog item and verify detail page - waitForAnimationToEnd - -# Navigate to Catalog tab (scroll-to-top if already active; normal tab tap otherwise) - tapOn: text: "Catalog" - waitForAnimationToEnd - -# iOS: returning to the Catalog tab can restore the search bar as first responder, -# overlaying the screen and suppressing the FlatList items from the accessibility tree. -# Dismiss it if visible. -- runFlow: - when: - platform: iOS - commands: - - runFlow: - when: - visible: - text: "Cancel" - commands: - - tapOn: - text: "Cancel" - - waitForAnimationToEnd - -# Wait for catalog items to load via testID. -# catalog-browse-flow already loaded items (30s wait) so these should appear -# quickly. Use a generous timeout to handle the case where this flow is run -# standalone (fresh load). -- extendedWaitUntil: - visible: - id: "catalog-item-card" - timeout: 30000 - -# Tap the first visible catalog item by testID. +# Wait for items to load +- assertVisible: + text: ".*items.*" +# Tap first visible item using index - tapOn: - id: "catalog-item-card" + id: "catalog:item-.*" + index: 0 - waitForAnimationToEnd - -# Wait for the detail screen container to confirm navigation happened. -# The add-to-pack button is below the fold in a ScrollView; on iOS XCTest -# does not expose off-screen ScrollView children in the accessibility tree -# until they are scrolled into view. -# Use 30s: CI network latency can delay initial data load past 15s. -- extendedWaitUntil: - visible: - id: "catalog-detail-content" - timeout: 30000 - -# Scroll the detail screen to bring the action buttons into view. +# Scroll down to find action buttons - scrollUntilVisible: element: id: "add-to-pack-button" direction: DOWN timeout: 15000 - - assertVisible: id: "add-to-pack-button" +- scrollUntilVisible: + element: + id: "view-retailer-button" + direction: DOWN + speed: 10 + timeout: 10000 - assertVisible: id: "view-retailer-button" - -# Go back -# iOS: XCTest synthetic swipes do not trigger UIScreenEdgePanGestureRecognizer. -# Tap the native navigation bar back button instead (accessibility label "Back"). +# Go back - platform specific - runFlow: when: - platform: iOS + platform: Android commands: - - tapOn: - text: "Back" - - waitForAnimationToEnd + - back - runFlow: when: - platform: Android + platform: iOS commands: - - back - - waitForAnimationToEnd + - tapOn: + text: ".*Back.*" +- waitForAnimationToEnd \ No newline at end of file diff --git a/.maestro/flows/dashboard/dashboard-tiles-flow.yaml b/.maestro/flows/dashboard/dashboard-tiles-flow.yaml index 159138fc2a..f4280fd513 100644 --- a/.maestro/flows/dashboard/dashboard-tiles-flow.yaml +++ b/.maestro/flows/dashboard/dashboard-tiles-flow.yaml @@ -1,18 +1,26 @@ appId: ${APP_ID} --- -# Dashboard Tiles Flow: Verify dashboard tab loads +# Dashboard Tiles Flow: Verify dashboard loads with key tiles - waitForAnimationToEnd -# Navigate to Dashboard tab +# Navigate to Dashboard (home tab) - tapOn: text: "Dashboard" - waitForAnimationToEnd -# Verify we landed on the Dashboard screen (large-title header). -# NativeWindUI ListItem tile titles are not exposed as individual nodes in -# the iOS accessibility tree, so we only assert on the screen header here. +# Verify key dashboard tiles are present +- scrollUntilVisible: + element: + id: "dashboard-tile-packrat-ai" + direction: DOWN - assertVisible: - text: "Dashboard" + id: "dashboard-tile-packrat-ai" + +# Scroll to check more tiles +- scrollUntilVisible: + element: + text: ".*Pack.*" + direction: DOWN # Return to stable state - tapOn: diff --git a/.maestro/flows/negative/empty-pack-submit-flow.yaml b/.maestro/flows/negative/empty-pack-submit-flow.yaml index 550732279e..2564865a31 100644 --- a/.maestro/flows/negative/empty-pack-submit-flow.yaml +++ b/.maestro/flows/negative/empty-pack-submit-flow.yaml @@ -2,55 +2,30 @@ appId: ${APP_ID} --- # Empty Pack Submit Flow: Verify form validation prevents empty pack creation - waitForAnimationToEnd - -# Navigate to Packs tab - tapOn: text: "Packs" - waitForAnimationToEnd - -# iOS: returning to the Packs tab can restore the search bar as first responder, -# overlaying the screen and suppressing the navigation bar's create-pack-button -# from the accessibility tree. Dismiss it if visible. -- runFlow: - when: - platform: iOS - commands: - - runFlow: - when: - visible: - text: "Cancel" - commands: - - tapOn: - text: "Cancel" - - waitForAnimationToEnd - -# Tap create pack button - tapOn: id: "create-pack-button" - waitForAnimationToEnd - # Try to submit without filling any fields - tapOn: id: "submit-pack-button" - waitForAnimationToEnd - -# Should still be on the form (submit button still visible = didn't navigate away) +# Should still be on the form (submit button still visible = validation blocked submit) - assertVisible: id: "submit-pack-button" - -# Go back -# iOS: pack/new is a modal (root of its own nav stack) — no system "Back" button. -# The modal has a Cancel button (testID: cancel-pack-form-button) added for this purpose. +# Dismiss the sheet - platform specific - runFlow: when: - platform: iOS + platform: Android commands: - - tapOn: - id: "cancel-pack-form-button" - - waitForAnimationToEnd + - back - runFlow: when: - platform: Android + platform: iOS commands: - - back - - waitForAnimationToEnd + - swipe: + direction: DOWN + duration: 300 +- waitForAnimationToEnd \ No newline at end of file diff --git a/.maestro/flows/negative/empty-trip-submit-flow.yaml b/.maestro/flows/negative/empty-trip-submit-flow.yaml index 1c921d907e..4907fdc477 100644 --- a/.maestro/flows/negative/empty-trip-submit-flow.yaml +++ b/.maestro/flows/negative/empty-trip-submit-flow.yaml @@ -2,55 +2,30 @@ appId: ${APP_ID} --- # Empty Trip Submit Flow: Verify form validation prevents empty trip creation - waitForAnimationToEnd - -# Navigate to Trips tab - tapOn: text: "Trips" - waitForAnimationToEnd - -# iOS: returning to the Trips tab can restore the search bar as first responder, -# overlaying the screen and suppressing the navigation bar's create-trip-button. -# Dismiss it if visible. -- runFlow: - when: - platform: iOS - commands: - - runFlow: - when: - visible: - text: "Cancel" - commands: - - tapOn: - text: "Cancel" - - waitForAnimationToEnd - -# Tap create trip button - tapOn: id: "create-trip-button" - waitForAnimationToEnd - # Try to submit without filling any fields - tapOn: id: "submit-trip-button" - waitForAnimationToEnd - -# Should still be on the form +# Should still be on the form (validation blocked submit) - assertVisible: id: "submit-trip-button" - -# Go back -# iOS: trip/new is a modal (root of its own nav stack) — no system "Back" button. -# The modal has a Cancel button (testID: cancel-trip-form-button) added for this purpose. +# Dismiss - platform specific - runFlow: when: - platform: iOS + platform: Android commands: - - tapOn: - id: "cancel-trip-form-button" - - waitForAnimationToEnd + - back - runFlow: when: - platform: Android + platform: iOS commands: - - back - - waitForAnimationToEnd + - swipe: + direction: DOWN + duration: 300 +- waitForAnimationToEnd \ No newline at end of file diff --git a/.maestro/flows/negative/invalid-login-flow.yaml b/.maestro/flows/negative/invalid-login-flow.yaml index b4df402d55..c578308a8f 100644 --- a/.maestro/flows/negative/invalid-login-flow.yaml +++ b/.maestro/flows/negative/invalid-login-flow.yaml @@ -7,58 +7,40 @@ appId: ${APP_ID} stopApp: true - waitForAnimationToEnd -# Dismiss Android ANR dialog that can appear during emulator boot -- runFlow: - when: - platform: Android - commands: - - runFlow: - when: - visible: - text: "isn't responding" - commands: - - tapOn: - text: "Wait" - - waitForAnimationToEnd - -# Wait for auth entry screen +# Navigate to Sign In - extendedWaitUntil: visible: - id: "sign-in-email-button" - timeout: 15000 + text: "Sign In" + timeout: 10000 -# Navigate to Sign In - tapOn: - id: "sign-in-email-button" + text: "Sign In" - waitForAnimationToEnd -# Fill in invalid email -- runFlow: - when: - platform: iOS - commands: - - tapOn: - text: "Email" - - inputText: "invalid@nonexistent.com" - - tapOn: - text: "Password" - - inputText: "wrongpassword123" +# Handle possible multi-step auth screens - runFlow: when: - platform: Android + notVisible: + text: "Email" commands: - tapOn: - id: "email-input" - - inputText: "invalid@nonexistent.com" - - tapOn: - id: "password-input" - - inputText: "wrongpassword123" + text: "Sign In" + - waitForAnimationToEnd + +# Fill in invalid credentials +- tapOn: + text: "Email" +- inputText: "invalid@nonexistent.com" + +- tapOn: + text: "Password" +- inputText: "wrongpassword123" - hideKeyboard -# Submit via testID — avoids "Submit" vs "Continue" platform difference +# Submit - tapOn: - id: "continue-button" + text: "Submit" - waitForAnimationToEnd # Should show Login Failed error dialog @@ -76,15 +58,5 @@ appId: ${APP_ID} - waitForAnimationToEnd # Should still be on login form -- runFlow: - when: - platform: iOS - commands: - - assertVisible: - text: "Email" -- runFlow: - when: - platform: Android - commands: - - assertVisible: - id: "email-input" +- assertVisible: + text: "Email" diff --git a/.maestro/flows/packs/add-item-actions-flow.yaml b/.maestro/flows/packs/add-item-actions-flow.yaml index 941ca413f5..e82fb3da01 100644 --- a/.maestro/flows/packs/add-item-actions-flow.yaml +++ b/.maestro/flows/packs/add-item-actions-flow.yaml @@ -8,28 +8,10 @@ appId: ${APP_ID} text: "Packs" - waitForAnimationToEnd -# Tap the test pack. -# iOS: use search + testID to avoid hitting the UITextField that shares the same text. -- runFlow: - when: - platform: iOS - commands: - - tapOn: - text: "Search..." - - eraseText: 100 # clear any text left from a previous flow before typing - - inputText: ${PACK_NAME} - - tapOn: - id: "pack-search-result" - - waitForAnimationToEnd - -# Android: tap pack card in the list. -- runFlow: - when: - platform: Android - commands: - - tapOn: - text: ${PACK_NAME} - - waitForAnimationToEnd +# Tap the test pack +- tapOn: + id: "packs:list-item-${PACK_NAME}" +- waitForAnimationToEnd # Tap Add Item button - tapOn: @@ -44,33 +26,30 @@ appId: ${APP_ID} - assertVisible: id: "add-from-catalog-option" -# Dismiss the bottom sheet -- back -- waitForAnimationToEnd - -# Go back to packs list -# iOS: XCTest synthetic swipes do not trigger UIScreenEdgePanGestureRecognizer. -# Tap the native navigation bar back button instead (accessibility label "Back"). -# The first tap may land on the sheet's dimmed overlay (dismissing it) rather than -# on the nav button. If we're still on Pack Details after the first tap, tap Back again. +# Dismiss the bottom sheet - platform specific +- runFlow: + when: + platform: Android + commands: + - back - runFlow: when: platform: iOS commands: - - tapOn: - text: "Back" - - waitForAnimationToEnd - - runFlow: - when: - visible: - id: "add-item-button" - commands: - - tapOn: - text: "Back" - - waitForAnimationToEnd + - swipe: + direction: DOWN + duration: 300 +- waitForAnimationToEnd +# Go back to packs list - platform specific - runFlow: when: platform: Android commands: - back - - waitForAnimationToEnd +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: ".*Back.*" +- waitForAnimationToEnd \ No newline at end of file diff --git a/.maestro/flows/packs/add-item-manual-flow.yaml b/.maestro/flows/packs/add-item-manual-flow.yaml index d80716df3a..d4b4f30318 100644 --- a/.maestro/flows/packs/add-item-manual-flow.yaml +++ b/.maestro/flows/packs/add-item-manual-flow.yaml @@ -37,18 +37,50 @@ appId: ${APP_ID} - waitForAnimationToEnd # Assert add item form loaded -- assertVisible: - text: "Item Name" +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: "backpack.fill, Item Name" +- runFlow: + when: + platform: Android + commands: + - assertVisible: + text: "Item Name" # Fill in Item Name -- tapOn: - text: "Item Name" -- inputText: "Test Headlamp" +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "backpack.fill, Item Name" + - inputText: "Test Headlamp" +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: "Item Name" + - inputText: "Test Headlamp" # Fill in Description -- tapOn: - text: "Description" -- inputText: "Lightweight headlamp for hiking" +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "info, Description" + - inputText: "Lightweight headlamp for hiking" +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: "Description" + - inputText: "Lightweight headlamp for hiking" # Dismiss keyboard - hideKeyboard diff --git a/.maestro/flows/packs/create-pack-flow.yaml b/.maestro/flows/packs/create-pack-flow.yaml index e72ee1848f..f8dab7156a 100644 --- a/.maestro/flows/packs/create-pack-flow.yaml +++ b/.maestro/flows/packs/create-pack-flow.yaml @@ -15,45 +15,37 @@ appId: ${APP_ID} - waitForAnimationToEnd -# iOS: the "Save Password?" Keychain prompt from login can appear late (up to ~2 min -# after login). Dismiss it here before touching form fields so it doesn't block inputs. -# If the Keychain prompt appeared while the modal was animating in, it may have closed -# the modal. Re-tap the create-pack-button if pack-name-input is not yet visible. +# Fill in Pack Name - runFlow: when: platform: iOS commands: - tapOn: - text: "Not Now" - optional: true - label: "Dismiss late iOS Save Password prompt" - - waitForAnimationToEnd - - runFlow: - when: - notVisible: - id: "pack-name-input" - commands: - - tapOn: - id: "create-pack-button" - - waitForAnimationToEnd - -# Wait for the modal form to be accessible before tapping the input. -# extendedWaitUntil is more reliable than bare waitForAnimationToEnd here because -# the modal slide-from-bottom animation can finish before the form is in XCTest's tree. -- extendedWaitUntil: - visible: - id: "pack-name-input" - timeout: 15000 - -# Fill in Pack Name -- tapOn: - id: "pack-name-input" -- inputText: ${PACK_NAME} + text: "move, Pack Name" + - inputText: ${PACK_NAME} +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: "Pack Name" + - inputText: ${PACK_NAME} # Fill in Description -- tapOn: - id: "pack-description-input" -- inputText: "Created by Maestro E2E test" +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "newspaper, Description" + - inputText: "Created by Maestro E2E test" +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: "Description" + - inputText: "Created by Maestro E2E test" # Dismiss the keyboard before submitting - hideKeyboard @@ -70,61 +62,15 @@ appId: ${APP_ID} id: "submit-pack-button" timeout: 15000 -# Assert we are back on the packs list +# Assert the pack was created - we should see the pack name in the list/details +# Also confirm we are on the packs list by checking for the create button - assertVisible: id: "create-pack-button" - -# iOS: the "Save Password?" keychain prompt can appear late — after returning -# from any form with a password field. Dismiss it before scrolling so it -# doesn't intercept taps or obscure the list. -- runFlow: - when: - platform: iOS - commands: - - runFlow: - when: - visible: - text: "Not Now" - commands: - - tapOn: - text: "Not Now" - - waitForAnimationToEnd - -# Verify the new pack is in the list. -# Android: scrollUntilVisible works correctly. New packs are at the BOTTOM of the oldest-first -# list, so direction: DOWN (Maestro swipes UP from center) reveals them. -- runFlow: - when: - platform: Android - commands: - - waitForAnimationToEnd - - scrollUntilVisible: - element: - text: ${PACK_NAME} - direction: DOWN - timeout: 120000 - speed: 80 - - assertVisible: - text: ${PACK_NAME} - -# iOS: scrollUntilVisible cannot reliably detect FlatList items through the LargeTitleHeader -# accessibility tree even when the item is visually present. Use the native search bar instead -# to filter to the new pack, then verify a result row appears. -# assertVisible by text fails on iOS because Pressable accessible={true} absorbs child Text -# nodes — the Text is not separately accessible. Check by testID instead. -- runFlow: - when: - platform: iOS - commands: - - waitForAnimationToEnd - - tapOn: - text: "Search..." - - waitForAnimationToEnd - - inputText: ${PACK_NAME} - - extendedWaitUntil: - visible: - id: "pack-search-result" - timeout: 30000 - - tapOn: - text: "Trips" - - waitForAnimationToEnd +- scrollUntilVisible: + element: + id: "packs:list-item-${PACK_NAME}" + direction: DOWN + timeout: 60000 + speed: 99 +- assertVisible: + id: "packs:list-item-${PACK_NAME}" diff --git a/.maestro/flows/packs/pack-detail-flow.yaml b/.maestro/flows/packs/pack-detail-flow.yaml index 80a5fb77bc..54f2e1e9bc 100644 --- a/.maestro/flows/packs/pack-detail-flow.yaml +++ b/.maestro/flows/packs/pack-detail-flow.yaml @@ -8,30 +8,10 @@ appId: ${APP_ID} text: "Packs" - waitForAnimationToEnd -# Tap the test pack. -# iOS: PackListScreen clears the search bar on blur, so search is not active when this -# flow starts. Re-activate search to filter to the pack, then tap the result row via -# testID to avoid hitting the UITextField (which contains the same text). -- runFlow: - when: - platform: iOS - commands: - - tapOn: - text: "Search..." - - eraseText: 100 # clear any text left from a previous flow before typing - - inputText: ${PACK_NAME} - - tapOn: - id: "pack-search-result" - - waitForAnimationToEnd - -# Android: search is not active; tap the pack card in the list. -- runFlow: - when: - platform: Android - commands: - - tapOn: - text: ${PACK_NAME} - - waitForAnimationToEnd +# Tap the test pack +- tapOn: + id: "packs:list-item-${PACK_NAME}" +- waitForAnimationToEnd # Assert pack detail elements via testIDs - assertVisible: @@ -45,19 +25,16 @@ appId: ${APP_ID} - assertVisible: text: ${PACK_NAME} -# Go back to packs list -# iOS: XCTest synthetic swipes do not trigger UIScreenEdgePanGestureRecognizer. -# Tap the native navigation bar back button instead (accessibility label "Back"). +# Go back to packs list - platform specific - runFlow: when: - platform: iOS + platform: Android commands: - - tapOn: - text: "Back" - - waitForAnimationToEnd + - back - runFlow: when: - platform: Android + platform: iOS commands: - - back - - waitForAnimationToEnd + - tapOn: + text: ".*Back.*" +- waitForAnimationToEnd diff --git a/.maestro/flows/trips/create-trip-flow.yaml b/.maestro/flows/trips/create-trip-flow.yaml index f3025a8233..6f49a92970 100644 --- a/.maestro/flows/trips/create-trip-flow.yaml +++ b/.maestro/flows/trips/create-trip-flow.yaml @@ -1,108 +1,112 @@ appId: ${APP_ID} --- -# Create Trip Flow: Navigate to trips tab and create a new trip +# Create Trip Flow - waitForAnimationToEnd - -# Navigate to the Trips tab - tapOn: text: "Trips" - - waitForAnimationToEnd - -# Tap the header "+" button (testID: create-trip-button) to open the trip creation form - tapOn: id: "create-trip-button" - - waitForAnimationToEnd - -# Fill in the Trip Name field -- tapOn: - id: "trip-name-input" -- inputText: ${TRIP_NAME} - -# Fill in the Description field -- tapOn: - id: "trip-description-input" -- inputText: "Created by Maestro E2E test" - -# Dismiss the keyboard before interacting with the date picker rows. -# Android: hideKeyboard keeps focus off the description so the submit button -# stays visible after the date pickers close. -# iOS: hideKeyboard fails on multiline TextInput (no toolbar "Done" button). -# Tap in the navigation bar title area instead — this blurs the description -# input and dismisses the keyboard without triggering any navigation action. -# Once the keyboard is gone the date rows are accessible to Maestro. +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "show map, Trip Name" + - inputText: ${TRIP_NAME} +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: "Trip Name" + - inputText: ${TRIP_NAME} +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "Description" + - inputText: "Created by Maestro E2E test" - runFlow: when: platform: Android commands: - - hideKeyboard + - tapOn: + text: "Description" + - inputText: "Created by Maestro E2E test" +# Tap neutral area to dismiss keyboard on iOS multiline field - runFlow: when: platform: iOS commands: - tapOn: - point: "50%,8%" + text: "TRIP DETAILS" - waitForAnimationToEnd +- hideKeyboard +- waitForAnimationToEnd # --- Start Date --- -# Use testID so the row is findable by XCTest accessibilityIdentifier even -# when the keyboard is still visible (text-based lookup fails when keyboard -# partially obscures the row on iOS). - tapOn: - id: "start-date-row" + id: "trips:start-date-input" - waitForAnimationToEnd - -- repeat: - times: ${START_TAPS} - commands: - - tapOn: "Next Month" - - runFlow: when: platform: Android commands: - # Android calendar shows bare day numbers; tap the day digit directly - - tapOn: "${START_DAY}" - - tapOn: "OK" - + - repeat: + times: ${START_TAPS} + commands: + - tapOn: + text: "Next Month" + - tapOn: + text: "${START_DAY} ${START_MONTH} ${START_YEAR}" + - tapOn: + text: "OK" - runFlow: when: platform: iOS commands: - # Inline calendar (display="inline") exposes day cells as bare numbers. - # Use anchored regex ^N$ so "4" matches only day 4, not "14" or "24". - - tapOn: "^${START_DAY}$" - tapOn: - point: "5%,5%" - + text: "${TODAY_DATE}" + - waitForAnimationToEnd + - tapOn: + text: "${START_DAY}" + - waitForAnimationToEnd + - tapOn: + point: "50%,90%" - waitForAnimationToEnd # --- End Date --- - tapOn: - id: "end-date-row" + id: "trips:end-date-input" - waitForAnimationToEnd - -- repeat: - times: ${END_TAPS} - commands: - - tapOn: "Next Month" - - runFlow: when: platform: Android commands: - - tapOn: "${END_DAY}" - - tapOn: "OK" - + - repeat: + times: ${END_TAPS} + commands: + - tapOn: + text: "Next Month" + - tapOn: + text: "${END_DAY} ${END_MONTH} ${END_YEAR}" + - tapOn: + text: "OK" - runFlow: when: platform: iOS commands: - - tapOn: "^${END_DAY}$" - tapOn: - point: "5%,5%" - + text: "${TODAY_DATE}" + - waitForAnimationToEnd + - tapOn: + text: "${END_DAY}" + - waitForAnimationToEnd + - tapOn: + point: "50%,90%" - waitForAnimationToEnd # --- Submit --- @@ -121,50 +125,12 @@ appId: ${APP_ID} # for the header create button, then verify the new trip appears - assertVisible: id: "create-trip-button" - -# iOS: the "Save Password?" keychain prompt can appear late — after returning -# from any form with a password field. Dismiss it before scrolling so it -# doesn't intercept taps or obscure the list. -- runFlow: - when: - platform: iOS - commands: - - runFlow: - when: - visible: - text: "Not Now" - commands: - - tapOn: - text: "Not Now" - - waitForAnimationToEnd - -# Verify the new trip is in the list. -# Android: scrollUntilVisible works correctly. -- runFlow: - when: - platform: Android - commands: - - waitForAnimationToEnd - - scrollUntilVisible: - element: - text: ${TRIP_NAME} - direction: DOWN - timeout: 30000 - speed: 40 - - assertVisible: - text: ${TRIP_NAME} - -# iOS: text-based assertions and scrollUntilVisible cannot reliably match FlatList Pressable -# items through the LargeTitleHeader accessibility tree. Use the testID (trip-list-item) -# instead. The search bar is intentionally avoided — activating it leaves the keyboard up -# and iOS restores the search bar as first responder on tab return, breaking downstream flows. -- runFlow: - when: - platform: iOS - commands: - - waitForAnimationToEnd - - extendedWaitUntil: - visible: - id: "trip-list-item" - timeout: 30000 +- scrollUntilVisible: + element: + id: "trips:list-item-${TRIP_NAME}" + direction: DOWN + timeout: 60000 + speed: 99 +- assertVisible: + id: "trips:list-item-${TRIP_NAME}" diff --git a/.maestro/flows/trips/trip-detail-flow.yaml b/.maestro/flows/trips/trip-detail-flow.yaml index cca5ad93a4..79f3fb9995 100644 --- a/.maestro/flows/trips/trip-detail-flow.yaml +++ b/.maestro/flows/trips/trip-detail-flow.yaml @@ -9,43 +9,24 @@ appId: ${APP_ID} - waitForAnimationToEnd # Tap the test trip -# iOS: text-based tapOn does not find FlatList Pressable items through the LargeTitleHeader -# accessibility tree. Use testID (trip-list-item) instead. -- runFlow: - when: - platform: iOS - commands: - - tapOn: - id: "trip-list-item" - - waitForAnimationToEnd -- runFlow: - when: - platform: Android - commands: - - tapOn: - text: ${TRIP_NAME} - - waitForAnimationToEnd +- tapOn: + id: "trips:list-item-${TRIP_NAME}" +- waitForAnimationToEnd -# Verify trip name is shown via testID. -# assertVisible: text: fails on iOS because nativewindui Text uses UITextView which -# exposes content as accessibilityValue, not accessibilityLabel — Maestro's text -# matcher only checks accessibilityLabel. The testID (trip-detail-name) is reliable. +# Verify trip name is shown - assertVisible: - id: "trip-detail-name" + text: ${TRIP_NAME} -# Go back to trips list -# iOS: XCTest synthetic swipes do not trigger UIScreenEdgePanGestureRecognizer. -# Tap the native navigation bar back button instead (accessibility label "Back"). +# Go back to trips list - platform specific - runFlow: when: - platform: iOS + platform: Android commands: - - tapOn: - text: "Back" - - waitForAnimationToEnd + - back - runFlow: when: - platform: Android + platform: iOS commands: - - back - - waitForAnimationToEnd + - tapOn: + text: ".*Back.*" +- waitForAnimationToEnd diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index a38571090a..13605fadb4 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -1,7 +1,9 @@ import { clientEnvs } from '@packrat/env/expo-client'; import { isString } from '@packrat/guards'; +import type { AlertMethods } from '@packrat/ui/nativewindui'; import { ActivityIndicator, + Alert as AlertComponent, Avatar, AvatarFallback, AvatarImage, @@ -13,6 +15,7 @@ import { ListSectionHeader, Text, } from '@packrat/ui/nativewindui'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; import { withAuthWall } from 'expo-app/features/auth/hocs'; @@ -30,7 +33,8 @@ import { testIds } from 'expo-app/lib/testIds'; import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; import * as FileSystem from 'expo-file-system/legacy'; import { Link, router, Stack } from 'expo-router'; -import { useState } from 'react'; +import * as Updates from 'expo-updates'; +import { useRef, useState } from 'react'; import { Alert, Linking, Platform, Pressable, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -242,14 +246,39 @@ function ListHeaderComponent() { function ListFooterComponent() { const { signOut } = useAuth(); + const { colors } = useColorScheme(); const { t } = useTranslation(); + const alertRef = useRef(null); const [isSigningOut, setIsSigningOut] = useState(false); const handleSignOut = async () => { try { setIsSigningOut(true); await signOut(); + alertRef.current?.alert({ + title: t('auth.loggedOut'), + message: t('auth.loggedOutMessage'), + materialIcon: { name: 'check-circle-outline', color: colors.green }, + buttons: [ + { + text: t('auth.stayLoggedOut'), + style: 'cancel', + onPress: async () => { + await AsyncStorage.setItem('skipped_login', 'true'); + await Updates.reloadAsync(); + }, + }, + { + text: t('auth.signInAgain'), + style: 'default', + onPress: async () => { + await AsyncStorage.setItem('skipped_login', 'false'); + await Updates.reloadAsync(); + }, + }, + ], + }); } catch (error) { console.error('Logout failed:', error); } finally { @@ -265,13 +294,22 @@ function ListFooterComponent() { disabled={isSigningOut} onPress={() => { if (hasUnsyncedChanges()) { - // Use native Alert on both platforms so the dialog buttons are - // accessible to automated testing tools (custom portal-based - // dialogs are not surfaced in XCTest/UIAutomator accessibility trees). - Alert.alert(t('profile.syncInProgress'), t('profile.syncMessage'), [ - { text: t('common.cancel'), style: 'cancel' }, - { text: t('auth.logOut'), style: 'destructive', onPress: handleSignOut }, - ]); + alertRef.current?.alert({ + title: t('profile.syncInProgress'), + message: t('profile.syncMessage'), + materialIcon: { name: 'repeat' }, + buttons: [ + { + text: t('common.cancel'), + style: 'cancel', + }, + { + text: t('auth.proceedLogOut'), + style: 'destructive', + onPress: handleSignOut, + }, + ], + }); return; } handleSignOut(); @@ -286,6 +324,7 @@ function ListFooterComponent() { {t('auth.logOut')} )} + diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 4213baab5f..c6b2be62fb 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -1,9 +1,7 @@ -import { use$ } from '@legendapp/state/react'; import { ActivityIndicator } from '@packrat/ui/nativewindui'; import { ThemeToggle } from 'expo-app/components/ThemeToggle'; -import { isLoadingAtom, needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; +import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { useAuthInit } from 'expo-app/features/auth/hooks/useAuthInit'; -import { isAuthed } from 'expo-app/features/auth/store'; import { getPackTemplateDetailOptions } from 'expo-app/features/pack-templates/utils/getPackTemplateDetailOptions'; import { getPackTemplateItemDetailOptions } from 'expo-app/features/pack-templates/utils/getPackTemplateItemDetailOptions'; import SyncBanner from 'expo-app/features/packs/components/SyncBanner'; @@ -12,12 +10,10 @@ import { getPackItemDetailOptions } from 'expo-app/features/packs/utils/getPackI import { getTripDetailOptions } from 'expo-app/features/trips/utils/getTripDetailOptions'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import type { TranslationFunction } from 'expo-app/lib/i18n/types'; -import { testIds } from 'expo-app/lib/testIds'; import 'expo-dev-client'; -import { type Href, router, Stack, useRouter } from 'expo-router'; +import { Stack } from 'expo-router'; import { useAtomValue } from 'jotai'; -import { useEffect, useRef } from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export { @@ -27,36 +23,11 @@ export { export default function AppLayout() { const isLoading = useAuthInit(); - const isAuthedValue = use$(isAuthed); const { t } = useTranslation(); const needsReauth = useAtomValue(needsReauthAtom); - const isLoadingGlobal = useAtomValue(isLoadingAtom); const insets = useSafeAreaInsets(); - // Latches true once we dispatch router.replace('/auth') on sign-out. - // Keeps the spinner rendered until AppLayout unmounts so that - // auth/index.tsx resetting isLoadingAtom=false never causes AppLayout - // to re-render its Stack mid-transition. If the Stack re-initialized - // while the root navigator was still committing the replace, it would - // re-register with React Navigation and override the in-flight navigation, - // landing the user back on the Trips/Profile screen instead of auth. - const hasNavigatedToAuthRef = useRef(false); - useEffect(() => { - if (isLoadingGlobal && !isAuthedValue) { - hasNavigatedToAuthRef.current = true; - // safe-cast: '/auth' is a compile-time string literal recognised by expo-router - router.replace('/auth' as Href); - } - }, [isLoadingGlobal, isAuthedValue]); - - // Show spinner when: (a) auth initialising on cold start, OR (b) a sign-out - // is in progress (isLoadingAtom=true) AND the user is no longer authenticated. - // The spinner unmounts NativeTabs so the useEffect above can dispatch to the - // root Stack. The !isAuthedValue guard keeps the Stack visible during re-auth - // sign-in, where isLoadingAtom is also true but the user is still authed. - // hasNavigatedToAuthRef keeps the spinner until AppLayout actually unmounts - // after the router.replace('/auth') transition completes. - if (isLoading || (isLoadingGlobal && !isAuthedValue) || hasNavigatedToAuthRef.current) { + if (isLoading) { return ( @@ -314,19 +285,12 @@ const getSettingsOptions = (t: TranslationFunction) => headerRight: () => , }) as const; -const getTripNewOptions = (t: TranslationFunction) => ({ - title: t('trips.createTrip'), - presentation: 'modal' as const, - animation: 'slide_from_bottom' as const, - headerLeft: () => { - const router = useRouter(); - return ( - router.back()} className="px-2"> - {t('common.cancel')} - - ); - }, -}); +const getTripNewOptions = (t: TranslationFunction) => + ({ + title: t('trips.createTrip'), + presentation: 'modal', + animation: 'slide_from_bottom', + }) as const; const getTripEditOptions = (t: TranslationFunction) => ({ @@ -347,19 +311,12 @@ const CONSENT_MODAL_OPTIONS = { animation: 'fade_from_bottom', // for android } as const; -const getPackNewOptions = (t: TranslationFunction) => ({ - title: t('packs.createPack'), - presentation: 'modal' as const, - animation: 'fade_from_bottom' as const, - headerLeft: () => { - const router = useRouter(); - return ( - router.back()} className="px-2"> - {t('common.cancel')} - - ); - }, -}); +const getPackNewOptions = (t: TranslationFunction) => + ({ + title: t('packs.createPack'), + presentation: 'modal', + animation: 'fade_from_bottom', // for android + }) as const; const getItemNewOptions = (t: TranslationFunction) => ({ diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index 8222e1f208..16d09d682b 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -110,44 +110,42 @@ export default function LoginScreen() { )} -
- - - - {(field) => ( - KeyboardController.setFocusTo('next')} - submitBehavior="submit" - autoFocus - onFocus={() => setFocusedTextField('email')} - onBlur={() => { - setFocusedTextField(null); - field.handleBlur(); - }} - keyboardType="email-address" - textContentType="emailAddress" - returnKeyType="next" - value={field.state.value} - onChangeText={field.handleChange} - errorMessage={field.state.meta.errors[0]?.message} - /> - )} - + + + + + + {(field) => ( + KeyboardController.setFocusTo('next')} + submitBehavior="submit" + autoFocus + onFocus={() => setFocusedTextField('email')} + onBlur={() => { + setFocusedTextField(null); + field.handleBlur(); + }} + keyboardType="email-address" + textContentType="emailAddress" + returnKeyType="next" + value={field.state.value} + onChangeText={field.handleChange} + errorMessage={field.state.meta.errors[0]?.message} + /> + )} + + - + {(field) => ( {Platform.OS === 'ios' ? ( - + [state.canSubmit, state.isSubmitting]}> {([canSubmit, _isSubmitting]) => ( )} - + [state.canSubmit, state.isSubmitting]}> {([canSubmit, _isSubmitting]) => (