diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000000..e59737d694 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,284 @@ +name: E2E Tests (Maestro) + +on: + push: + branches: [main, development] + paths: + - "apps/expo/**" + - ".maestro/**" + - ".github/workflows/e2e-tests.yml" + # Note: Using `pull_request` (not `pull_request_target`) so forked PRs get + # CI feedback on their own code. Secrets are unavailable for forks, so + # the job is skipped via the `if` condition on the job below. + pull_request: + branches: [main, development] + paths: + - "apps/expo/**" + - ".maestro/**" + - ".github/workflows/e2e-tests.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + MAESTRO_VERSION: 1.40.0 + +jobs: + ios-e2e: + name: iOS E2E Tests + runs-on: macos-15 + timeout-minutes: 120 + # Skip on forked PRs — secrets are not available in forks + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + + env: + TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install + + - name: Setup Expo + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Install Maestro CLI + run: | + export MAESTRO_VERSION="${{ env.MAESTRO_VERSION }}" + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "${HOME}/.maestro/bin" >> "${GITHUB_PATH}" + + - name: Build iOS app for simulator + run: | + cd apps/expo + mkdir -p build + eas build \ + --platform ios \ + --profile e2e \ + --non-interactive \ + --local \ + --output ./build/PackRat-sim.tar.gz + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + + - name: Extract iOS simulator build + run: | + mkdir -p apps/expo/build/extracted + tar -xzf apps/expo/build/PackRat-sim.tar.gz -C apps/expo/build/extracted + APP_PATH=$(find apps/expo/build/extracted -name "*.app" -maxdepth 3 | head -1) + if [ -z "$APP_PATH" ]; then + echo "::error::No .app bundle found in simulator build archive" + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> "$GITHUB_ENV" + + - name: Boot iOS Simulator + run: | + # Find the latest available iOS runtime (prefer 18, fall back to 17, then any available iOS runtime) + IOS_RUNTIME=$(xcrun simctl list runtimes --json \ + | python3 -c " + import sys, json + data = json.load(sys.stdin) + runtimes = data.get('runtimes', []) + available = [r for r in runtimes if r.get('isAvailable', False) and r.get('platform', r.get('name','')).startswith('iOS')] + # Fallback: also match by name containing 'iOS' in case 'platform' key is absent + if not available: + available = [r for r in runtimes if r.get('isAvailable', False) and 'iOS' in r.get('name','')] + ios18 = [r for r in available if 'iOS 18' in r.get('name','')] + ios17 = [r for r in available if 'iOS 17' in r.get('name','')] + chosen = ios18 or ios17 or available + if not chosen: + print('', file=sys.stderr) + sys.exit(1) + print(chosen[-1]['identifier']) + ") + if [ -z "$IOS_RUNTIME" ]; then + echo "::error::No available iOS runtime found on this runner" + exit 1 + fi + # Find iPhone 15 device type (base or Pro) + DEVICE_TYPE=$(xcrun simctl list devicetypes --json \ + | python3 -c " + import sys, json + types = json.load(sys.stdin)['devicetypes'] + iphone15 = [d for d in types if 'iPhone 15' in d.get('name','') and 'Pro' not in d.get('name','') and 'Plus' not in d.get('name','') and 'Max' not in d.get('name','')] + fallback = [d for d in types if 'iPhone 15' in d.get('name','')] + chosen = (iphone15 or fallback) + print(chosen[0]['identifier'] if chosen else 'com.apple.CoreSimulator.SimDeviceType.iPhone-15') + ") + echo "Using runtime: $IOS_RUNTIME" + echo "Using device type: $DEVICE_TYPE" + DEVICE_ID=$(xcrun simctl create "PackRat-E2E" "$DEVICE_TYPE" "$IOS_RUNTIME") + xcrun simctl boot "$DEVICE_ID" + # Wait for the simulator to be fully booted before continuing + xcrun simctl bootstatus "$DEVICE_ID" -b + echo "SIMULATOR_UDID=$DEVICE_ID" >> "$GITHUB_ENV" + + - name: Install app on simulator + run: | + xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH" + + - name: Run Maestro E2E tests + run: | + mkdir -p test-results + maestro --device "$SIMULATOR_UDID" test \ + --format junit \ + --output test-results/maestro-results.xml \ + .maestro/config.yaml + env: + TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + TRIP_NAME: E2E-Trip-${{ github.run_id }} + PACK_NAME: E2E-Pack-${{ github.run_id }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-e2e-results + path: test-results/ + retention-days: 30 + + - name: Upload Maestro screenshots and videos on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-e2e-failure-artifacts + path: ~/.maestro/tests/ + retention-days: 7 + + - name: Shutdown simulator + if: always() + run: | + xcrun simctl shutdown "$SIMULATOR_UDID" || true + xcrun simctl delete "$SIMULATOR_UDID" || true + + android-e2e: + name: Android E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 120 + # Skip on forked PRs — secrets are not available in forks + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + + env: + TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install + + - name: Setup Expo + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Install Maestro CLI + run: | + export MAESTRO_VERSION="${{ env.MAESTRO_VERSION }}" + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "${HOME}/.maestro/bin" >> "${GITHUB_PATH}" + + - name: Build Android APK for emulator + run: | + cd apps/expo + mkdir -p build + eas build \ + --platform android \ + --profile e2e \ + --non-interactive \ + --local \ + --output ./build/PackRat.apk + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + + - name: Enable KVM for hardware acceleration + 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 E2E tests on Android emulator + uses: reactivecircus/android-emulator-runner@v2.34.0 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + avd-name: PackRat-E2E + force-avd-creation: false + emulator-options: >- + -no-snapshot-save -no-window -gpu swiftshader_indirect + -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: | + mkdir -p test-results + adb install apps/expo/build/PackRat.apk + maestro test \ + --format junit \ + --output test-results/maestro-results.xml \ + .maestro/config-android.yaml + env: + TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + TRIP_NAME: E2E-Trip-${{ github.run_id }} + PACK_NAME: E2E-Pack-${{ github.run_id }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-e2e-results + path: test-results/ + retention-days: 30 + + - name: Upload Maestro screenshots and videos on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-e2e-failure-artifacts + path: ~/.maestro/tests/ + retention-days: 7 diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 0000000000..fcdfeccd90 --- /dev/null +++ b/.maestro/README.md @@ -0,0 +1,99 @@ +# PackRat Maestro E2E Tests + +This directory contains end-to-end tests for the PackRat mobile app using [Maestro](https://maestro.mobile.dev). + +## Structure + +```text +.maestro/ +├── config.yaml # Maestro suite configuration & flow order +└── flows/ + ├── setup/ + │ └── clear-state.yaml # Clears app state before test suite + ├── auth/ + │ ├── login-flow.yaml # Sign in with email and password + │ └── logout-flow.yaml # Sign out from the app + ├── trips/ + │ └── create-trip-flow.yaml # Create a new trip + └── packs/ + └── create-pack-flow.yaml # Create a new pack +``` + +## Prerequisites + +1. **Install Maestro CLI**: + ```bash + curl -Ls "https://get.maestro.mobile.dev" | bash + ``` + +2. **Set up environment variables** for test credentials: + ```bash + export TEST_EMAIL="your-test-account@example.com" + export TEST_PASSWORD="your-test-password" + ``` + +3. **Build and install the app** on a simulator/device (run from `apps/expo/`): + ```bash + eas build --platform ios --profile e2e --local --output ./build/PackRat-sim.tar.gz + mkdir -p ./build/extracted + tar -xzf ./build/PackRat-sim.tar.gz -C ./build/extracted + xcrun simctl install booted ./build/extracted/*.app + ``` + +## Running Tests + +### Run all flows +```bash +maestro test .maestro/config.yaml +``` + +### Run a single flow +```bash +maestro test .maestro/flows/auth/login-flow.yaml +``` + +### Run with JUnit output (for CI) +```bash +maestro test --format junit --output test-results.xml .maestro/config.yaml +``` + +### Run on a specific simulator +```bash +maestro --device test .maestro/config.yaml +``` + +## CI/CD + +Tests run automatically via GitHub Actions (`.github/workflows/e2e-tests.yml`) on: +- Every push to `main` or `development` that touches `apps/expo/**`, `.maestro/**`, or `.github/workflows/e2e-tests.yml` +- Every pull request targeting `main` or `development` that touches the same paths + +> **Note:** Forked pull requests are skipped because repository secrets are not available. + +### Required Secrets + +Configure these GitHub repository secrets for CI: + +| Secret | Description | +|--------|-------------| +| `EXPO_TOKEN` | Expo access token for EAS builds | +| `E2E_TEST_EMAIL` | Email address of the E2E test account | +| `E2E_TEST_PASSWORD` | Password of the E2E test account | +| `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` | GitHub token for private package access | + +## Writing New Tests + +Flows are standard YAML files following the [Maestro flow syntax](https://maestro.mobile.dev/api-reference/commands). + +Key conventions: +- All flows start with `appId: com.andrewbierman.packrat` +- Prefer `testID` / `accessibilityIdentifier` selectors first (e.g., `tapOn: { id: "submitButton" }`); use `text` to match an element's iOS `accessibilityLabel` or visible text (e.g., `tapOn: { text: "Submit" }`); `accessibilityLabel` is **not** a valid Maestro selector key +- Use `waitForAnimationToEnd` after navigation actions +- Use `runFlow: { when: { visible: ... } }` for conditional steps +- Use environment variables (e.g., `${TRIP_NAME}`) for entity names to keep each test run unique + +## Troubleshooting + +- **Element not found**: Run `maestro studio` for interactive element inspection +- **Flaky tests**: Add `waitForAnimationToEnd` before assertions +- **Simulator issues**: Ensure the simulator is booted before running tests diff --git a/.maestro/config-android.yaml b/.maestro/config-android.yaml new file mode 100644 index 0000000000..2b89bb5b6c --- /dev/null +++ b/.maestro/config-android.yaml @@ -0,0 +1,18 @@ +# Maestro E2E Test Suite Configuration – Android +# https://maestro.mobile.dev + +appId: com.packratai.mobile + +flows: + - flows/setup/*.yaml + - flows/auth/*.yaml + - flows/trips/*.yaml + - flows/packs/*.yaml + +executionOrder: + flowsOrder: + - flows/setup/clear-state.yaml + - flows/auth/login-flow.yaml + - flows/trips/create-trip-flow.yaml + - flows/packs/create-pack-flow.yaml + - flows/auth/logout-flow.yaml diff --git a/.maestro/config.yaml b/.maestro/config.yaml new file mode 100644 index 0000000000..7326ddb677 --- /dev/null +++ b/.maestro/config.yaml @@ -0,0 +1,18 @@ +# Maestro E2E Test Suite Configuration +# https://maestro.mobile.dev + +appId: com.andrewbierman.packrat + +flows: + - flows/setup/*.yaml + - flows/auth/*.yaml + - flows/trips/*.yaml + - flows/packs/*.yaml + +executionOrder: + flowsOrder: + - flows/setup/clear-state.yaml + - flows/auth/login-flow.yaml + - flows/trips/create-trip-flow.yaml + - flows/packs/create-pack-flow.yaml + - flows/auth/logout-flow.yaml diff --git a/.maestro/flows/auth/login-flow.yaml b/.maestro/flows/auth/login-flow.yaml new file mode 100644 index 0000000000..27332fe45e --- /dev/null +++ b/.maestro/flows/auth/login-flow.yaml @@ -0,0 +1,43 @@ +# Login Flow: Navigate to auth screen and sign in with email and password +- launchApp + +# Wait for the auth entry screen to appear +- waitForAnimationToEnd + +# Tap the "Sign In" (email) button to navigate to the email login screen +- tapOn: + id: "sign-in-email-button" + +# Wait for login form to appear +- waitForAnimationToEnd + +# Fill in the email field +- tapOn: + id: "email-input" +- typeText: ${TEST_EMAIL} + +# Fill in the password field +- tapOn: + id: "password-input" +- typeText: ${TEST_PASSWORD} + +# Dismiss the keyboard before submitting +- hideKeyboard + +# Submit login form via the continue button +- tapOn: + id: "continue-button" + +# Wait for navigation to complete — login can take a few seconds +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: "Packs" + timeout: 15000 + +# Assert we are now logged in by navigating to Packs and verifying a stable testID +- tapOn: + text: "Packs" +- waitForAnimationToEnd +- assertVisible: + id: "create-pack-button" diff --git a/.maestro/flows/auth/logout-flow.yaml b/.maestro/flows/auth/logout-flow.yaml new file mode 100644 index 0000000000..ad4dd7dd49 --- /dev/null +++ b/.maestro/flows/auth/logout-flow.yaml @@ -0,0 +1,49 @@ +# Logout Flow: Navigate to profile and sign out +- waitForAnimationToEnd + +# Navigate to the Profile tab +- tapOn: + text: "Profile" + +- waitForAnimationToEnd + +# 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) +# The dialog button also reads "Log Out", so use index 1 to select the dialog button +# (index 0 is the original row behind the dialog). +- runFlow: + when: + visible: + text: "Sync in progress" + commands: + - tapOn: + text: "Log Out" + index: 1 + +- waitForAnimationToEnd + +# Handle post-logout dialog - choose to stay logged out +- runFlow: + when: + visible: + text: "Stay logged out" + commands: + - tapOn: + text: "Stay logged out" + +- waitForAnimationToEnd + +# Assert we are back on the auth screen +- assertVisible: + text: "Sign In" diff --git a/.maestro/flows/packs/create-pack-flow.yaml b/.maestro/flows/packs/create-pack-flow.yaml new file mode 100644 index 0000000000..172bbb00c0 --- /dev/null +++ b/.maestro/flows/packs/create-pack-flow.yaml @@ -0,0 +1,46 @@ +# Create Pack Flow: Navigate to packs tab and create a new pack +- waitForAnimationToEnd + +# Navigate to the Packs tab +- tapOn: + text: "Packs" + +- waitForAnimationToEnd + +# Tap the header "+" button (testID: create-pack-button) to open the pack creation form +- tapOn: + id: "create-pack-button" + +- waitForAnimationToEnd + +# Fill in Pack Name +- tapOn: + text: "Pack Name" +- typeText: ${PACK_NAME} + +# Fill in Description +- tapOn: + text: "Description" +- typeText: "Created by Maestro E2E test" + +# Dismiss the keyboard before submitting +- hideKeyboard + +# Submit the form via the submit button (testID: submit-pack-button) +- tapOn: + id: "submit-pack-button" + +- waitForAnimationToEnd + +# Wait until the submit button disappears, confirming navigation away from the form +- extendedWaitUntil: + notVisible: + id: "submit-pack-button" + timeout: 15000 + +# 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" +- assertVisible: + text: ${PACK_NAME} diff --git a/.maestro/flows/setup/clear-state.yaml b/.maestro/flows/setup/clear-state.yaml new file mode 100644 index 0000000000..965fa449ff --- /dev/null +++ b/.maestro/flows/setup/clear-state.yaml @@ -0,0 +1,4 @@ +# Clear app state before running tests to ensure a clean environment +- launchApp: + clearState: true + stopApp: true diff --git a/.maestro/flows/trips/create-trip-flow.yaml b/.maestro/flows/trips/create-trip-flow.yaml new file mode 100644 index 0000000000..c92098f53d --- /dev/null +++ b/.maestro/flows/trips/create-trip-flow.yaml @@ -0,0 +1,46 @@ +# Create Trip Flow: Navigate to trips tab and create a new trip +- 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: + text: "Trip Name" +- typeText: ${TRIP_NAME} + +# Fill in the Description field +- tapOn: + text: "Description" +- typeText: "Created by Maestro E2E test" + +# Dismiss the keyboard before submitting +- hideKeyboard + +# Submit the form via the submit button (testID: submit-trip-button) +- tapOn: + id: "submit-trip-button" + +- waitForAnimationToEnd + +# Wait until the submit button disappears, confirming navigation away from the form +- extendedWaitUntil: + notVisible: + id: "submit-trip-button" + timeout: 15000 + +# Assert we are back on the trips list (not still on the form) by checking +# for the header create button, then verify the new trip appears +- assertVisible: + id: "create-trip-button" +- assertVisible: + text: ${TRIP_NAME} diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index d3ffad6c0a..b716c0ef0c 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -3,6 +3,7 @@ import { useForm } from '@tanstack/react-form'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { TestIds } from 'expo-app/lib/testIds'; import { Link, router, Stack, useLocalSearchParams } from 'expo-router'; import { useAtomValue } from 'jotai'; import * as React from 'react'; @@ -112,6 +113,7 @@ export default function LoginScreen() { {(field) => ( {(field) => ( [state.canSubmit, state.isSubmitting]}> {([canSubmit, _isSubmitting]) => (