diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 70d452f878..cc76a5ebb4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -11,9 +11,6 @@ on: - "!apps/expo/vitest.config.ts" - ".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: @@ -35,9 +32,12 @@ permissions: env: MAESTRO_VERSION: 2.3.0 - # Suppress the per-invocation "Anonymous analytics enabled" banner and - # the network round-trip it implies on every CI run. MAESTRO_CLI_NO_ANALYTICS: "true" + # Local Postgres used by both jobs — no cloud DB dependency. + # Use 127.0.0.1 explicitly: on some runners `localhost` resolves to ::1 (IPv6) + # while Postgres only listens on 127.0.0.1 (IPv4), causing connection failures. + E2E_DB_URL: postgres://e2e_user:e2e_pass@127.0.0.1:5432/packrat_e2e + E2E_API_URL: http://localhost:8787 jobs: e2e-gate: @@ -51,13 +51,14 @@ jobs: name: Verify E2E secrets are available env: E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} run: | - if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then + if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$E2E_TEST_PASSWORD" ] && [ -n "$E2E_BETTER_AUTH_SECRET" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::notice::E2E secrets not configured — skipping E2E tests" + echo "::notice::E2E secrets not configured — skipping E2E tests (need E2E_TEST_EMAIL + E2E_TEST_PASSWORD + E2E_BETTER_AUTH_SECRET)" fi ios-e2e: @@ -68,8 +69,6 @@ jobs: timeout-minutes: 120 env: - # The E2E user is upserted into the dev DB by the seed step below, - # so both email and password are driven entirely by repo secrets. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} @@ -100,6 +99,145 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + - name: Add node_modules bin to PATH + run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> $GITHUB_PATH + + # ── Local API stack ──────────────────────────────────────────────────── + + - name: Start local Postgres with pgvector + run: | + PG_VER=16 + BREW_PREFIX="$(brew --prefix)" + PG_BIN="${BREW_PREFIX}/opt/postgresql@${PG_VER}/bin" + PG_DATA="$HOME/pgdata-e2e" + + # Stop any running Homebrew Postgres service regardless of version to free port 5432. + brew services list | awk '/postgresql/{print $1}' \ + | xargs -I{} brew services stop {} 2>/dev/null || true + + brew install "postgresql@${PG_VER}" + + # Build pgvector from source against postgresql@16's pg_config. + # Brew's pgvector formula targets whichever PG version its formula + # depends on (may not be @16), so we compile directly to be certain. + git clone --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector-build + make -C /tmp/pgvector-build PG_CONFIG="$PG_BIN/pg_config" + make -C /tmp/pgvector-build install PG_CONFIG="$PG_BIN/pg_config" + + "$PG_BIN/initdb" -D "$PG_DATA" --encoding=UTF8 --auth=trust + printf '\nport = 5432\nlisten_addresses = '"'"'127.0.0.1'"'"'\n' \ + >> "$PG_DATA/postgresql.conf" + "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$HOME/pg.log" start + + READY=false + for i in $(seq 1 30); do + if "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 > /dev/null 2>&1; then + READY=true + break + fi + sleep 2 + done + if [ "$READY" != "true" ]; then + echo "::error::PostgreSQL did not become ready" + cat "$HOME/pg.log" + exit 1 + fi + + "$PG_BIN/psql" -h 127.0.0.1 -p 5432 -U "$(whoami)" postgres \ + -c "CREATE USER e2e_user WITH PASSWORD 'e2e_pass' SUPERUSER;" + "$PG_BIN/psql" -h 127.0.0.1 -p 5432 -U "$(whoami)" postgres \ + -c "CREATE DATABASE packrat_e2e OWNER e2e_user;" + + - name: Write wrangler .dev.vars + env: + E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} + # Optional: real weather keys improve trip-detail test fidelity. + # Fall back to schema-valid stubs — weather errors are non-fatal. + WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }} + OPENWEATHER_KEY: ${{ secrets.OPENWEATHER_KEY }} + run: | + # Write static (non-secret) vars first via heredoc. + # Schema-valid stubs are used for APIs not exercised by the master flow + # (AI, email, R2, social OAuth) so env-validation does not throw. + cat > packages/api/.dev.vars << 'DEVVARS' + NEON_DATABASE_URL=postgres://e2e_user:e2e_pass@127.0.0.1:5432/packrat_e2e + NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@127.0.0.1:5432/packrat_e2e + BETTER_AUTH_URL=http://localhost:8787 + EXPO_PUBLIC_API_URL=http://localhost:8787 + ADMIN_USERNAME=admin + ADMIN_PASSWORD=gobuffs + PACKRAT_API_KEY=secret + EMAIL_PROVIDER=resend + RESEND_API_KEY=re_e2e_stub_not_used_in_maestro_flows + EMAIL_FROM=no-reply@e2e.packrattest.local + AI_PROVIDER=openai + OPENAI_API_KEY=sk-e2e-stub-not-used-in-maestro-master-flow + GOOGLE_GENERATIVE_AI_API_KEY=e2e-stub-not-used + PERPLEXITY_API_KEY=pplx-e2e-stub-not-used-in-maestro-master-flow + GOOGLE_CLIENT_ID=e2e-google-client-id + GOOGLE_CLIENT_SECRET=e2e-google-client-secret + APPLE_CLIENT_ID=com.packratai.e2e + APPLE_PRIVATE_KEY=e2e-apple-private-key-stub + APPLE_KEY_ID=E2EAPLKEY1 + APPLE_TEAM_ID=E2ETEAMID1 + CLOUDFLARE_ACCOUNT_ID=e2eaccountid + CLOUDFLARE_AI_GATEWAY_ID=ai-chat-gateway + R2_ACCESS_KEY_ID=e2er2accesskey + R2_SECRET_ACCESS_KEY=e2er2secretkey1234567890abcdefghij + PACKRAT_BUCKET_R2_BUCKET_NAME=packrat-bucket-e2e + PACKRAT_GUIDES_BUCKET_R2_BUCKET_NAME=packrat-guides-e2e + PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME=packrat-scrapy-e2e + R2_PUBLIC_URL=https://pub-e2estub.r2.dev + PACKRAT_GUIDES_RAG_NAME=packrat-guides-rag + PACKRAT_GUIDES_BASE_URL=https://guides.packratai.com/ + DEVVARS + + # Inject secrets separately (printf avoids shell expansion on special chars). + printf 'BETTER_AUTH_SECRET=%s\n' "${E2E_BETTER_AUTH_SECRET}" \ + >> packages/api/.dev.vars + printf 'WEATHER_API_KEY=%s\n' \ + "${WEATHER_API_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + printf 'OPENWEATHER_KEY=%s\n' \ + "${OPENWEATHER_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + + - name: Run DB migrations + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + run: bun run --filter @packrat/api db:migrate + + - name: Seed E2E test user + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }} + run: bun run --filter @packrat/api db:seed:e2e-user + + - name: Start wrangler dev (background) + working-directory: packages/api + run: | + WRANGLER_LOG=warn \ + WRANGLER_SEND_METRICS=false \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ + > /tmp/wrangler-dev.log 2>&1 & + echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" + + echo "Waiting for API to be ready on ${{ env.E2E_API_URL }}/health ..." + RETRIES=30 + until curl -sf "${{ env.E2E_API_URL }}/health" > /dev/null; do + RETRIES=$((RETRIES - 1)) + if [ "$RETRIES" -le 0 ]; then + echo "::error::wrangler dev did not become ready in time" + cat /tmp/wrangler-dev.log + exit 1 + fi + sleep 2 + done + echo "API ready." + + # ── iOS build ────────────────────────────────────────────────────────── + - name: Setup Expo uses: expo/expo-github-action@v8 with: @@ -127,7 +265,19 @@ jobs: uses: actions/cache@v4 with: path: apps/expo/build/PackRat-sim.tar.gz - key: ios-sim-${{ runner.os }}-${{ hashFiles('apps/expo/**', 'packages/**', 'bun.lock') }} + # 'local-api' suffix keeps this cache slot separate from any builds + # that embedded the deployed API URL. + key: ios-sim-${{ runner.os }}-local-api-${{ hashFiles('apps/expo/**', 'packages/**', 'bun.lock') }} + restore-keys: | + ios-sim-${{ runner.os }}-local-api- + + - name: Pin API URL to local wrangler dev + if: steps.ios-build-cache.outputs.cache-hit != 'true' + run: | + # .env.local has highest priority in Expo's env hierarchy and + # overrides whatever EXPO_PUBLIC_API_URL the EAS 'preview' environment + # injects during the local build. + echo "EXPO_PUBLIC_API_URL=${{ env.E2E_API_URL }}" > apps/expo/.env.local - name: Build iOS app for simulator if: steps.ios-build-cache.outputs.cache-hit != 'true' @@ -144,6 +294,8 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + # ── Maestro ──────────────────────────────────────────────────────────── + - name: Cache Maestro CLI id: maestro-cache uses: actions/cache@v4 @@ -173,9 +325,6 @@ jobs: - name: Boot iOS Simulator run: | - # Pick the newest available iOS runtime on this runner. GitHub's - # macOS images ship different iOS runtimes over time; hard-coding a - # specific major version breaks CI every time Apple ships a new one. IOS_RUNTIME=$(xcrun simctl list runtimes --json \ | python3 -c " import sys, json, re @@ -194,9 +343,6 @@ jobs: print(picked['identifier']) ") - # Prefer a plain iPhone (no Pro/Plus/Max) from a recent generation. - # Fall back through 17 -> 16 -> 15 -> any available plain iPhone so - # the workflow survives device-type churn on the runner images. DEVICE_TYPE=$(xcrun simctl list devicetypes --json \ | python3 -c " import sys, json, re @@ -215,8 +361,6 @@ jobs: def gen(d): m = re.search(r'iPhone\s+(\d+)', d.get('name','')) return int(m.group(1)) if m else 0 - # Prefer generations 15/16/17 (current sweet spot), then fall back - # to whatever plain iPhone is newest. preferred = [d for d in plain if gen(d) in (17, 16, 15)] pool = preferred if preferred else plain pool.sort(key=gen, reverse=True) @@ -236,29 +380,20 @@ jobs: run: | xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH" - - name: Seed E2E test user in dev DB - run: bun run --filter @packrat/api db:seed:e2e-user - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} - E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} - - name: Run Maestro E2E tests run: | mkdir -p test-results bun test:e2e:ios --device "$SIMULATOR_UDID" --format junit --output test-results/maestro-results.xml env: TEST_EMAIL: ${{ env.TEST_EMAIL }} - TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + TEST_PASSWORD: ${{ env.TEST_PASSWORD }} TRIP_NAME: E2E-Trip-${{ github.run_id }} PACK_NAME: E2E-Pack-${{ github.run_id }} - # EAS e2e profile builds the preview variant, whose bundle id is - # suffixed with ".preview" via app.config.ts. APP_ID: com.andrewbierman.packrat.preview - # xcuitest driver boot on cold GH runners can exceed the 180s - # default. Give it 10 minutes before declaring a timeout. MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000" + # ── Artifacts & cleanup ──────────────────────────────────────────────── + - name: Upload test results if: always() uses: actions/upload-artifact@v7 @@ -275,6 +410,19 @@ jobs: path: ~/.maestro/tests/ retention-days: 7 + - name: Upload wrangler dev log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ios-wrangler-dev-log + path: /tmp/wrangler-dev.log + if-no-files-found: ignore + retention-days: 7 + + - name: Stop wrangler dev + if: always() + run: kill "${WRANGLER_PID:-}" 2>/dev/null || true + - name: Shutdown simulator if: always() run: | @@ -289,16 +437,26 @@ jobs: timeout-minutes: 120 env: - # The E2E user is upserted into the dev DB by the seed step below, - # so both email and password are driven entirely by repo secrets. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: e2e_user + POSTGRES_PASSWORD: e2e_pass + POSTGRES_DB: packrat_e2e + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Free disk space on runner - # Gradle builds of this RN app fail with OOM / no-space on stock - # ubuntu-latest. Prune large preinstalled toolchains we don't use. - # Keep the Android SDK/NDK — the Gradle build links against them. run: | sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL \ "$AGENT_TOOLSDIRECTORY/Ruby" "$AGENT_TOOLSDIRECTORY/PyPy" || true @@ -311,10 +469,6 @@ jobs: df -h - name: Configure swap for Gradle - # R8/ProGuard during :app:packageRelease regularly OOMs on 16GB - # runners without swap. Add a generous swap file. ubuntu-latest - # ships with /swapfile already active, so use a distinct path - # and dd (fallocate errors "Text file busy" on the live one). run: | sudo swapoff -a || true sudo rm -f /mnt/swapfile-ci @@ -327,6 +481,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -345,6 +504,99 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + - name: Add node_modules bin to PATH + run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> $GITHUB_PATH + + # ── Local API stack ──────────────────────────────────────────────────── + # Postgres with pgvector runs as a job-level service container (see services: above). + + - name: Write wrangler .dev.vars + env: + E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} + WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }} + OPENWEATHER_KEY: ${{ secrets.OPENWEATHER_KEY }} + run: | + cat > packages/api/.dev.vars << 'DEVVARS' + NEON_DATABASE_URL=postgres://e2e_user:e2e_pass@127.0.0.1:5432/packrat_e2e + NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@127.0.0.1:5432/packrat_e2e + BETTER_AUTH_URL=http://localhost:8787 + EXPO_PUBLIC_API_URL=http://localhost:8787 + ADMIN_USERNAME=admin + ADMIN_PASSWORD=gobuffs + PACKRAT_API_KEY=secret + EMAIL_PROVIDER=resend + RESEND_API_KEY=re_e2e_stub_not_used_in_maestro_flows + EMAIL_FROM=no-reply@e2e.packrattest.local + AI_PROVIDER=openai + OPENAI_API_KEY=sk-e2e-stub-not-used-in-maestro-master-flow + GOOGLE_GENERATIVE_AI_API_KEY=e2e-stub-not-used + PERPLEXITY_API_KEY=pplx-e2e-stub-not-used-in-maestro-master-flow + GOOGLE_CLIENT_ID=e2e-google-client-id + GOOGLE_CLIENT_SECRET=e2e-google-client-secret + APPLE_CLIENT_ID=com.packratai.e2e + APPLE_PRIVATE_KEY=e2e-apple-private-key-stub + APPLE_KEY_ID=E2EAPLKEY1 + APPLE_TEAM_ID=E2ETEAMID1 + CLOUDFLARE_ACCOUNT_ID=e2eaccountid + CLOUDFLARE_AI_GATEWAY_ID=ai-chat-gateway + R2_ACCESS_KEY_ID=e2er2accesskey + R2_SECRET_ACCESS_KEY=e2er2secretkey1234567890abcdefghij + PACKRAT_BUCKET_R2_BUCKET_NAME=packrat-bucket-e2e + PACKRAT_GUIDES_BUCKET_R2_BUCKET_NAME=packrat-guides-e2e + PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME=packrat-scrapy-e2e + R2_PUBLIC_URL=https://pub-e2estub.r2.dev + PACKRAT_GUIDES_RAG_NAME=packrat-guides-rag + PACKRAT_GUIDES_BASE_URL=https://guides.packratai.com/ + DEVVARS + + printf 'BETTER_AUTH_SECRET=%s\n' "${E2E_BETTER_AUTH_SECRET}" \ + >> packages/api/.dev.vars + printf 'WEATHER_API_KEY=%s\n' \ + "${WEATHER_API_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + printf 'OPENWEATHER_KEY=%s\n' \ + "${OPENWEATHER_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + + - name: Run DB migrations + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + run: bun run --filter @packrat/api db:migrate + + - name: Seed E2E test user + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }} + run: bun run --filter @packrat/api db:seed:e2e-user + + - name: Start wrangler dev (background) + working-directory: packages/api + run: | + # --ip 0.0.0.0 is required: Android emulator reaches the host via + # 10.0.2.2; wrangler must listen on all interfaces to accept it. + # getApiUrl() in apps/expo swaps 'localhost' -> '10.0.2.2' at runtime. + WRANGLER_LOG=warn \ + WRANGLER_SEND_METRICS=false \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ + > /tmp/wrangler-dev.log 2>&1 & + echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" + + echo "Waiting for API to be ready on ${{ env.E2E_API_URL }}/health ..." + RETRIES=30 + until curl -sf "${{ env.E2E_API_URL }}/health" > /dev/null; do + RETRIES=$((RETRIES - 1)) + if [ "$RETRIES" -le 0 ]; then + echo "::error::wrangler dev did not become ready in time" + cat /tmp/wrangler-dev.log + exit 1 + fi + sleep 2 + done + echo "API ready." + + # ── Android build ────────────────────────────────────────────────────── + - name: Setup Expo uses: expo/expo-github-action@v8 with: @@ -367,28 +619,21 @@ jobs: restore-keys: | gradle-${{ runner.os }}- - - name: Cache Maestro CLI - id: maestro-cache-android - uses: actions/cache@v4 - with: - path: ~/.maestro - key: maestro-${{ env.MAESTRO_VERSION }}-${{ runner.os }} - - - name: Install Maestro CLI - if: steps.maestro-cache-android.outputs.cache-hit != 'true' - run: | - export MAESTRO_VERSION="${{ env.MAESTRO_VERSION }}" - curl -Ls "https://get.maestro.mobile.dev" | bash - - - name: Add Maestro to PATH - run: echo "${HOME}/.maestro/bin" >> "${GITHUB_PATH}" - - name: Cache Android APK build id: android-build-cache uses: actions/cache@v4 with: path: apps/expo/build/PackRat.apk - key: android-apk-${{ runner.os }}-${{ hashFiles('apps/expo/**', 'packages/**', 'bun.lock') }} + # Separate cache slot from non-local-api builds. + key: android-apk-${{ runner.os }}-local-api-${{ hashFiles('apps/expo/**', 'packages/**', 'bun.lock') }} + restore-keys: | + android-apk-${{ runner.os }}-local-api- + + - name: Pin API URL to local wrangler dev + if: steps.android-build-cache.outputs.cache-hit != 'true' + run: | + # getApiUrl() replaces 'localhost' with '10.0.2.2' at runtime on Android. + echo "EXPO_PUBLIC_API_URL=${{ env.E2E_API_URL }}" > apps/expo/.env.local - name: Build Android APK for emulator if: steps.android-build-cache.outputs.cache-hit != 'true' @@ -405,6 +650,8 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + # ── Maestro ──────────────────────────────────────────────────────────── + - name: Enable KVM for hardware acceleration run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ @@ -435,12 +682,21 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Seed E2E test user in dev DB - run: bun run --filter @packrat/api db:seed:e2e-user - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} - E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + - name: Cache Maestro CLI + id: maestro-cache-android + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ env.MAESTRO_VERSION }}-${{ runner.os }} + + - name: Install Maestro CLI + if: steps.maestro-cache-android.outputs.cache-hit != 'true' + run: | + export MAESTRO_VERSION="${{ env.MAESTRO_VERSION }}" + curl -Ls "https://get.maestro.mobile.dev" | bash + + - name: Add Maestro to PATH + run: echo "${HOME}/.maestro/bin" >> "${GITHUB_PATH}" - name: Run Maestro E2E tests on Android emulator uses: reactivecircus/android-emulator-runner@v2.37.0 @@ -460,22 +716,19 @@ jobs: adb shell pm disable-user com.android.launcher3 || true adb shell pm disable-user com.google.android.apps.nexuslauncher || true adb install apps/expo/build/PackRat.apk - # Give System UI time to fully initialize before launching tests. - # The emulator's System UI can raise an ANR dialog ("System UI isn't - # responding") in the seconds after boot. Without this wait, Maestro - # starts while that dialog is still blocking the screen. sleep 10 - # Dismiss any lingering ANR / crash dialogs left over from emulator boot adb shell input keyevent KEYCODE_BACK 2>/dev/null || true adb shell input keyevent KEYCODE_BACK 2>/dev/null || true bash .github/scripts/e2e.sh android --format junit --output test-results/maestro-results.xml env: TEST_EMAIL: ${{ env.TEST_EMAIL }} - TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + TEST_PASSWORD: ${{ env.TEST_PASSWORD }} TRIP_NAME: E2E-Trip-${{ github.run_id }} PACK_NAME: E2E-Pack-${{ github.run_id }} APP_ID: com.packratai.mobile.preview + # ── Artifacts & cleanup ──────────────────────────────────────────────── + - name: Upload test results if: always() uses: actions/upload-artifact@v7 @@ -491,3 +744,16 @@ jobs: name: android-e2e-failure-artifacts path: ~/.maestro/tests/ retention-days: 7 + + - name: Upload wrangler dev log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: android-wrangler-dev-log + path: /tmp/wrangler-dev.log + if-no-files-found: ignore + retention-days: 7 + + - name: Stop wrangler dev + if: always() + run: kill "${WRANGLER_PID:-}" 2>/dev/null || true diff --git a/.maestro/flows/auth/login-flow.yaml b/.maestro/flows/auth/login-flow.yaml index 7f99fecdaa..6ceda2160b 100644 --- a/.maestro/flows/auth/login-flow.yaml +++ b/.maestro/flows/auth/login-flow.yaml @@ -17,104 +17,165 @@ appId: ${APP_ID} text: "Wait" - waitForAnimationToEnd -# Wait for the auth entry screen — ensures sign-in button is rendered before tapping -- extendedWaitUntil: - visible: - id: "sign-in-email-button" - timeout: 15000 - -# 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. -# 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: - when: - platform: iOS - commands: - - extendedWaitUntil: - visible: - text: "Email" - timeout: 35000 +# Give the app time to render its initial screen before checking for overlays. - waitForAnimationToEnd -# Fill in the email field -- runFlow: - when: - platform: iOS - commands: - - tapOn: - text: "Email" - - inputText: "${TEST_EMAIL}" +# Dev client only: if the Metro bundle failed to load (e.g. server was unreachable), +# dismiss the "Error loading app" alert so we can retry the dev-server picker. - runFlow: when: - platform: Android + visible: + text: "Error loading app" commands: - tapOn: - id: "email-input" - - inputText: "${TEST_EMAIL}" + text: "OK" + - waitForAnimationToEnd -# Fill in the password field +# Dev client only: if the dev server picker appears on iOS (saved Metro URL was +# cleared or is stale), tap the discovered server row identified by METRO_HOST:METRO_PORT. - runFlow: when: platform: iOS commands: - - tapOn: - text: "Password" - - inputText: ${TEST_PASSWORD} + - runFlow: + when: + visible: + text: "DEVELOPMENT SERVERS" + commands: + - waitForAnimationToEnd + - tapOn: + text: "http://${METRO_HOST}:${METRO_PORT}" + - waitForAnimationToEnd + +# Dev client only: dismiss the Expo "Developer Tools" intro + dev-menu sheet that +# appears on the first launch after clearState. +# Step 1: Tap "Continue" on the intro card to advance past it (opens the dev menu sheet). +# Step 2: Tap the scrim above the sheet to dismiss the dev menu. - runFlow: when: - platform: Android + visible: + text: "Continue" commands: - tapOn: - id: "password-input" - - inputText: ${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 + text: "Continue" + - waitForAnimationToEnd + - runFlow: + when: + visible: + text: "Reload" + commands: + - tapOn: + point: "50%,10%" + - waitForAnimationToEnd -# Handle transient network error: if the API call failed, dismiss the alert and retry once. +# Sign in only if the auth entry screen is shown. +# Maestro's clearState does NOT clear iOS Keychain / SecureStore, so on runs +# after a successful login the session token survives and the app opens straight +# to Dashboard. Wrapping the entire sign-in block in a `runFlow when visible` +# lets both cases (needs login / already logged in) reach the Packs assertion. - runFlow: when: visible: - text: "Network request failed" + id: "sign-in-email-button" commands: + # Tap the "Sign In" (email) button to navigate to the email login screen - tapOn: - text: "OK" + id: "sign-in-email-button" + + # Wait for login form to appear. + # 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: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: "Email" + timeout: 35000 - waitForAnimationToEnd + + # Fill in the email field + - runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "Email" + - inputText: "${TEST_EMAIL}" + - runFlow: + when: + platform: Android + commands: + - tapOn: + id: "email-input" + - inputText: "${TEST_EMAIL}" + + # Fill in the password field + - runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "Password" + - inputText: ${TEST_PASSWORD} + - runFlow: + when: + platform: Android + commands: + - tapOn: + id: "password-input" + - inputText: ${TEST_PASSWORD} + + # Dismiss the keyboard before submitting + - hideKeyboard + + # Submit login form via the continue button - tapOn: id: "continue-button" - - waitForAnimationToEnd -# iOS only: dismiss the system "Save Password?" Keychain prompt that appears -# after submitting any form with a password field (textContentType="password"). -# This prompt is a blocking OS-level dialog and would intercept subsequent taps -# if not handled. Real users can choose to save — in CI we always skip it. -- runFlow: - when: - platform: iOS - commands: - - tapOn: - text: "Not Now" - optional: true - label: "Dismiss iOS Save Password prompt" + # Wait for the API call to complete: the button shows "Loading..." during the + # network round-trip. waitForAnimationToEnd returns as soon as the screen is + # visually stable, but "Loading..." is static — wait explicitly for it to disappear. + - extendedWaitUntil: + notVisible: + text: "Loading.*" + timeout: 20000 + + # Handle transient errors: dismiss the alert and retry once for any failure dialog. + - runFlow: + when: + visible: + text: "Login Failed" + commands: + - tapOn: + text: "OK" + - waitForAnimationToEnd + - tapOn: + id: "continue-button" + - extendedWaitUntil: + notVisible: + text: "Loading.*" + timeout: 20000 + + # iOS only: dismiss the system "Save Password?" Keychain prompt that appears + # after submitting any form with a password field (textContentType="password"). + # This prompt is a blocking OS-level dialog and would intercept subsequent taps + # if not handled. Real users can choose to save — in CI we always skip it. + - runFlow: + when: + platform: iOS + commands: + - tapOn: + text: "Not Now" + optional: true + label: "Dismiss iOS Save Password prompt" # Wait for the main tab bar to appear — confirms we are logged in and on the main app. -# We only assert the tab bar is visible here; navigation to specific tabs is left to -# subsequent flows (dashboard-tiles-flow taps Packs, providing a stable entry point for -# create-pack-flow). Navigating to Packs directly after login is unreliable on iOS -# because Expo Router's post-login routing is still settling at this point. +# This assertion covers both flows: just signed-in AND already-authenticated (session +# survived clearState because Keychain is not wiped by Maestro's clearState on iOS). - waitForAnimationToEnd - extendedWaitUntil: visible: text: "Packs" - timeout: 35000 \ No newline at end of file + timeout: 35000 diff --git a/.maestro/flows/packs/create-pack-flow.yaml b/.maestro/flows/packs/create-pack-flow.yaml index f8dab7156a..1ee14b6df8 100644 --- a/.maestro/flows/packs/create-pack-flow.yaml +++ b/.maestro/flows/packs/create-pack-flow.yaml @@ -3,11 +3,15 @@ appId: ${APP_ID} # Create Pack Flow: Navigate to packs tab and create a new pack - waitForAnimationToEnd -# Navigate to the Packs tab +# Navigate to the Packs tab and wait until the header create button is visible. +# Use extendedWaitUntil rather than a bare tap+wait to handle cases where the +# animation from the previous flow hasn't fully settled. - tapOn: text: "Packs" - -- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: "create-pack-button" + timeout: 10000 # Tap the header "+" button (testID: create-pack-button) to open the pack creation form - tapOn: diff --git a/.maestro/flows/setup/clear-state.yaml b/.maestro/flows/setup/clear-state.yaml index e2ebe7c94d..6a9c7c4e2f 100644 --- a/.maestro/flows/setup/clear-state.yaml +++ b/.maestro/flows/setup/clear-state.yaml @@ -18,3 +18,23 @@ appId: ${APP_ID} - tapOn: text: "Wait" - waitForAnimationToEnd + +# Dev client only: clearState wipes the saved Metro server URL, causing the +# development server picker to appear on the next launch. +# Tap the discovered server row (identified by the Mac's LAN IP + port) so the +# saved URL is the routable IP, not localhost (which resolves to the simulator +# itself, not the host machine). METRO_HOST and METRO_PORT are passed by the +# test runner; they default to the values used in the local dev setup. +- runFlow: + when: + platform: iOS + commands: + - runFlow: + when: + visible: + text: "DEVELOPMENT SERVERS" + commands: + - waitForAnimationToEnd + - tapOn: + text: "http://${METRO_HOST}:${METRO_PORT}" + - waitForAnimationToEnd diff --git a/packages/api/.dev.vars.e2e.example b/packages/api/.dev.vars.e2e.example new file mode 100644 index 0000000000..c5d4d3f347 --- /dev/null +++ b/packages/api/.dev.vars.e2e.example @@ -0,0 +1,73 @@ +# E2E local overrides — copy to .dev.vars.e2e and fill in real secret values. +# The DB, API URL, and Auth URL are pre-configured for local Docker Postgres. +# All other keys should match your main packages/api/.dev.vars. + +# ── Database (local Docker, port 5435) ───────────────────────────────────── +NEON_DATABASE_URL=postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e +NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e + +# ── API & Auth URLs (wrangler dev on localhost) ───────────────────────────── +EXPO_PUBLIC_API_URL=http://localhost:8787 +BETTER_AUTH_URL=http://localhost:8787 +BETTER_AUTH_SECRET=dev-better-auth-secret-32-characters-long-minimum + +# ── E2E credentials (set these to match the seeded test user) ─────────────── +E2E_TEST_EMAIL=e2e@packrattest.local +E2E_TEST_PASSWORD=E2eTestPass123! + +# ── JWT ───────────────────────────────────────────────────────────────────── +JWT_SECRET= + +# ── AI ────────────────────────────────────────────────────────────────────── +OPENAI_API_KEY= +GOOGLE_GENERATIVE_AI_API_KEY= +PERPLEXITY_API_KEY= +CLOUDFLARE_AI_GATEWAY_ID=ai-chat-gateway +AI_PROVIDER=openai + +# ── Email ──────────────────────────────────────────────────────────────────── +EMAIL_PROVIDER=resend +RESEND_API_KEY= +EMAIL_FROM=no-reply@transactional.packratai.com + +# ── Password reset ─────────────────────────────────────────────────────────── +PASSWORD_RESET_SECRET= + +# ── Weather ────────────────────────────────────────────────────────────────── +WEATHER_API_KEY= +OPENWEATHER_KEY= + +# ── Cloudflare R2 Storage ──────────────────────────────────────────────────── +CLOUDFLARE_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +PACKRAT_BUCKET_R2_BUCKET_NAME=packrat-bucket-preview +PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME=packrat-scrapy-bucket +PACKRAT_GUIDES_BUCKET_R2_BUCKET_NAME=packrat-guides +EXPO_PUBLIC_R2_PUBLIC_URL=https://pub-c3852b07b730407889986338ca3ef0e5.r2.dev +R2_PUBLIC_URL=https://pub-c3852b07b730407889986338ca3ef0e5.r2.dev + +# ── Misc ───────────────────────────────────────────────────────────────────── +PACKRAT_API_KEY=secret +ADMIN_USERNAME=admin +ADMIN_PASSWORD=gobuffs +PACKRAT_GUIDES_RAG_NAME=packrat-guides-rag +PACKRAT_GUIDES_BASE_URL=https://guides.packratai.com/ + +# ── Google OAuth ───────────────────────────────────────────────────────────── +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ── Maps ───────────────────────────────────────────────────────────────────── +EXPO_PUBLIC_GOOGLE_MAPS_API_KEY= + +# ── Sentry ─────────────────────────────────────────────────────────────────── +SENTRY_DSN= + +# ── Apple Sign In ───────────────────────────────────────────────────────────── +APPLE_CLIENT_ID= +APPLE_PRIVATE_KEY= +APPLE_KEY_ID= +APPLE_TEAM_ID= diff --git a/packages/api/.gitignore b/packages/api/.gitignore index dccd134734..2fdcede70e 100644 --- a/packages/api/.gitignore +++ b/packages/api/.gitignore @@ -8,6 +8,7 @@ node_modules/ .wrangler/ .dev.vars +.dev.vars.e2e # yarn/pnp files - using bun in prod .pnp.cjs diff --git a/packages/api/README.e2e-local.md b/packages/api/README.e2e-local.md new file mode 100644 index 0000000000..f001af0b89 --- /dev/null +++ b/packages/api/README.e2e-local.md @@ -0,0 +1,93 @@ +# Local Maestro E2E — API Setup + +Run the full Maestro e2e suite against a local Postgres database and a local +`wrangler dev` API — no Neon cloud, no shared dev DB. + +## Prerequisites + +| Tool | Notes | +|------|-------| +| Docker Desktop | Must be running | +| Bun | Already required by the monorepo | +| Maestro CLI | `curl -Ls https://get.maestro.mobile.dev \| bash` | +| iOS Simulator | Xcode installed + at least one simulator booted | + +## Quick start + +```bash +# 1. One-time setup: generate .dev.vars.e2e from your existing .dev.vars +cd packages/api +bun run dev:e2e:init + +# 2. Start Postgres + run migrations + seed e2e user + launch wrangler dev +bun run dev:e2e +``` + +The API is now live at **http://localhost:8787**. + +## How the stack connects + +```text +iOS Simulator ──────► localhost:8787 (wrangler dev) + │ + ▼ + localhost:5435 (Docker Postgres — packrat_e2e) +``` + +The iOS Simulator on macOS shares the Mac's loopback, so `localhost` works +without any special network config. For a real device on the same Wi-Fi, use +your Mac's LAN IP and rebuild the app with: + +```bash +EXPO_PUBLIC_API_URL=http://:8787 +``` + +## Running Maestro flows + +```bash +# In another terminal — wrangler dev must be running first +maestro test .maestro/master-flow.yaml \ + --env TEST_EMAIL=e2e@packrattest.local \ + --env TEST_PASSWORD=E2eTestPass123! +``` + +Or with the full suite runner: + +```bash +bash .maestro/run-suite.sh +``` + +## Stopping + +```bash +bun run --filter @packrat/api dev:e2e:stop # keep Postgres data +bun run --filter @packrat/api dev:e2e:stop -- --volumes # wipe DB too +``` + +## Full reset (wipe DB + restart) + +```bash +bun run --filter @packrat/api dev:e2e:reset +``` + +## How vars are layered + +`e2e-local-start.sh` passes `--env-file .dev.vars.e2e` to `wrangler dev`. +Wrangler merges the env file on top of any `.dev.vars` present, so e2e +overrides win. The key overrides are: + +| Var | Local value | +|-----|-------------| +| `NEON_DATABASE_URL` | `postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e` | +| `NEON_DATABASE_URL_READONLY` | same as above | +| `EXPO_PUBLIC_API_URL` | `http://localhost:8787` | +| `BETTER_AUTH_URL` | `http://localhost:8787` | + +All other vars (AI keys, R2, email) come from your base `.dev.vars`. + +## DB connection — why no wsproxy? + +The `db/index.ts` `createConnection` helper detects a standard `postgres://` +URL (not on `neon.tech`/`neon.com`) and automatically switches to `pg.Pool` +(node-postgres) instead of the Neon serverless WebSocket driver. No wsproxy +needed locally. diff --git a/packages/api/docker-compose.e2e.yml b/packages/api/docker-compose.e2e.yml new file mode 100644 index 0000000000..b7a5022beb --- /dev/null +++ b/packages/api/docker-compose.e2e.yml @@ -0,0 +1,19 @@ +services: + postgres-e2e: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: packrat_e2e + POSTGRES_USER: e2e_user + POSTGRES_PASSWORD: e2e_pass + ports: + - "127.0.0.1:5435:5432" + volumes: + - postgres_e2e_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U e2e_user -d packrat_e2e"] + interval: 3s + timeout: 5s + retries: 15 + +volumes: + postgres_e2e_data: diff --git a/packages/api/package.json b/packages/api/package.json index 7964bb1a55..8cd17032ee 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,6 +25,10 @@ "deploy": "wrangler deploy --minify", "deploy:dev": "wrangler deploy --minify -e=dev", "dev": "wrangler dev -e=dev", + "dev:e2e": "bash scripts/e2e-local-start.sh", + "dev:e2e:init": "bash scripts/e2e-local-init.sh", + "dev:e2e:reset": "bash scripts/e2e-local-stop.sh --volumes && bash scripts/e2e-local-start.sh", + "dev:e2e:stop": "bash scripts/e2e-local-stop.sh", "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:unit:coverage": "vitest run --config vitest.unit.config.ts --coverage", diff --git a/packages/api/scripts/e2e-local-init.sh b/packages/api/scripts/e2e-local-init.sh new file mode 100755 index 0000000000..846190556b --- /dev/null +++ b/packages/api/scripts/e2e-local-init.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# e2e-local-init.sh — generate packages/api/.dev.vars.e2e for local Maestro e2e. +# +# Copies your existing .dev.vars (or the main-checkout copy if that exists) +# and overrides the DB + API URLs to point at local Docker Postgres. +# +# Run once per worktree setup, or whenever you want to reset the e2e vars. +# The generated .dev.vars.e2e is gitignored. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(cd "${API_DIR}/../.." && pwd)" + +E2E_DB_URL="postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e" +OUT="${API_DIR}/.dev.vars.e2e" + +# Candidate source files (in order of preference) +CANDIDATES=( + "${API_DIR}/.dev.vars" + "${REPO_ROOT}/../development/packages/api/.dev.vars" +) + +SOURCE="" +for candidate in "${CANDIDATES[@]}"; do + if [[ -f "$candidate" ]]; then + SOURCE="$candidate" + break + fi +done + +if [[ -z "$SOURCE" ]]; then + echo "Error: Could not find a base .dev.vars file." + echo " Checked:" + for c in "${CANDIDATES[@]}"; do echo " $c"; done + echo "" + echo "Copy .dev.vars.e2e.example to .dev.vars.e2e and fill in your secrets manually." + exit 1 +fi + +echo "Using base vars from: ${SOURCE}" + +# Stream the base file, overriding the keys that differ for local e2e. +while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + NEON_DATABASE_URL=*) echo "NEON_DATABASE_URL=${E2E_DB_URL}" ;; + NEON_DATABASE_URL_READONLY=*) echo "NEON_DATABASE_URL_READONLY=${E2E_DB_URL}" ;; + EXPO_PUBLIC_API_URL=*) echo "EXPO_PUBLIC_API_URL=http://localhost:8787" ;; + BETTER_AUTH_URL=*) echo "BETTER_AUTH_URL=http://localhost:8787" ;; + *) echo "$line" ;; + esac +done < "$SOURCE" > "$OUT" + +# Append e2e credentials if not already present. +if ! grep -q "^E2E_TEST_EMAIL=" "$OUT"; then + echo "" >> "$OUT" + echo "E2E_TEST_EMAIL=${E2E_TEST_EMAIL:-e2e@packrattest.local}" >> "$OUT" +fi +if ! grep -q "^E2E_TEST_PASSWORD=" "$OUT"; then + echo "E2E_TEST_PASSWORD=${E2E_TEST_PASSWORD:-E2eTestPass123!}" >> "$OUT" +fi + +echo "Generated: ${OUT}" +echo "" +echo "Next steps:" +echo " 1. Review ${OUT} and confirm the values look correct." +echo " 2. Run: scripts/e2e-local-start.sh" diff --git a/packages/api/scripts/e2e-local-start.sh b/packages/api/scripts/e2e-local-start.sh new file mode 100755 index 0000000000..98605e6ef3 --- /dev/null +++ b/packages/api/scripts/e2e-local-start.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# e2e-local-start.sh — spin up local Postgres + wrangler dev for Maestro e2e. +# +# Prerequisites: +# - Docker running +# - .dev.vars.e2e generated (run scripts/e2e-local-init.sh if missing) +# - Bun installed +# +# The API will be available at http://localhost:8787 +# iOS Simulator can reach it at http://localhost:8787 (shared loopback on macOS). +# For a real device on the same Wi-Fi, use your Mac's LAN IP instead: +# EXPO_PUBLIC_API_URL=http://:8787 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API_DIR="$(dirname "$SCRIPT_DIR")" +COMPOSE_FILE="${API_DIR}/docker-compose.e2e.yml" +E2E_VARS="${API_DIR}/.dev.vars.e2e" +E2E_DB_URL="postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e" + +# ── Preflight ─────────────────────────────────────────────────────────────── +if ! command -v docker &>/dev/null; then + echo "Error: Docker not found. Install Docker Desktop and try again." + exit 1 +fi + +if [[ ! -f "$E2E_VARS" ]]; then + echo "Error: ${E2E_VARS} not found." + echo "Run first: bun run --filter @packrat/api dev:e2e:init" + exit 1 +fi + +# ── Start Postgres ─────────────────────────────────────────────────────────── +echo "▶ Starting local Postgres (packrat_e2e on port 5435)..." +docker compose -f "$COMPOSE_FILE" up -d + +echo "▶ Waiting for Postgres to be ready..." +RETRIES=30 +until docker compose -f "$COMPOSE_FILE" exec -T postgres-e2e \ + pg_isready -U e2e_user -d packrat_e2e &>/dev/null; do + RETRIES=$((RETRIES - 1)) + if [[ $RETRIES -le 0 ]]; then + echo "Error: Postgres did not become healthy in time." + docker compose -f "$COMPOSE_FILE" logs postgres-e2e + exit 1 + fi + sleep 1 +done +echo " Postgres ready." + +# ── Migrations ─────────────────────────────────────────────────────────────── +echo "▶ Running schema migrations..." +( + cd "$API_DIR" + NEON_DATABASE_URL="$E2E_DB_URL" bun run db:migrate +) + +# ── Seed E2E user ──────────────────────────────────────────────────────────── +E2E_EMAIL="${E2E_TEST_EMAIL:-$(grep '^E2E_TEST_EMAIL=' "$E2E_VARS" 2>/dev/null || true | cut -d= -f2-)}" +E2E_PASS="${E2E_TEST_PASSWORD:-$(grep '^E2E_TEST_PASSWORD=' "$E2E_VARS" 2>/dev/null || true | cut -d= -f2-)}" +E2E_EMAIL="${E2E_EMAIL:-e2e@packrattest.local}" +E2E_PASS="${E2E_PASS:-E2eTestPass123!}" + +echo "▶ Seeding E2E test user (${E2E_EMAIL})..." +( + cd "$API_DIR" + NEON_DATABASE_URL="$E2E_DB_URL" \ + E2E_TEST_EMAIL="$E2E_EMAIL" \ + E2E_TEST_PASSWORD="$E2E_PASS" \ + bun run db:seed:e2e-user +) + +# ── Wrangler dev ───────────────────────────────────────────────────────────── +echo "" +echo "▶ Starting wrangler dev on http://localhost:8787 ..." +echo " Using env file: ${E2E_VARS}" +echo " Press Ctrl+C to stop." +echo "" + +cd "$API_DIR" +# --env-file layers e2e vars on top of any existing .dev.vars; +# --ip 0.0.0.0 also exposes the API on the LAN (useful for real device testing). +exec wrangler dev -e dev \ + --env-file "$E2E_VARS" \ + --ip 0.0.0.0 diff --git a/packages/api/scripts/e2e-local-stop.sh b/packages/api/scripts/e2e-local-stop.sh new file mode 100755 index 0000000000..59aedb5801 --- /dev/null +++ b/packages/api/scripts/e2e-local-stop.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# e2e-local-stop.sh — tear down the local Postgres e2e stack. +# +# Stops and removes the Docker containers started by e2e-local-start.sh. +# Pass --volumes to also drop the Postgres data volume (full reset). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API_DIR="$(dirname "$SCRIPT_DIR")" +COMPOSE_FILE="${API_DIR}/docker-compose.e2e.yml" + +EXTRA_FLAGS=() +if [[ "${1:-}" == "--volumes" || "${1:-}" == "-v" ]]; then + EXTRA_FLAGS+=(--volumes) + echo "▶ Stopping and removing containers + data volume..." +else + echo "▶ Stopping containers (data volume preserved)..." + echo " Pass --volumes to also wipe the Postgres data." +fi + +docker compose -f "$COMPOSE_FILE" down "${EXTRA_FLAGS[@]}" +echo "Done." diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index f3ce6057c1..2496f4e311 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -3,33 +3,48 @@ * * getAuth(env) is called per-request so each isolate invocation picks up the * correct KV binding, credentials, and DB connection. The result is cached - * in a WeakMap keyed by the raw env object so the instance is reused across + * in a Map keyed by NEON_DATABASE_URL so the same instance is reused across * requests within the same isolate lifetime. */ import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { expo } from '@better-auth/expo'; -import { neon } from '@neondatabase/serverless'; import { generateAppleClientSecret, verifyPasswordCompat } from '@packrat/api/auth/auth.helpers'; +import { createConnection } from '@packrat/api/db'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; import * as schema from '@packrat/db'; import { betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; -import { drizzle } from 'drizzle-orm/neon-http'; // ─── Per-isolate auth instance cache ───────────────────────────────────────── +// Stores the in-flight Promise so concurrent requests that arrive before the +// first initialization completes all await the same Promise rather than each +// kicking off a redundant build. Evicted on rejection so the next call retries. +// Keyed by NEON_DATABASE_URL|BETTER_AUTH_URL — miniflare creates a new env +// object per request, so a WeakMap never hits; the URL composite key is stable +// within an isolate lifetime and distinguishes different env configurations. // biome-ignore lint/suspicious/noExplicitAny: Better Auth's generic type parameter is too specific to the exact plugin set — can't use ReturnType here -const authCache = new WeakMap(); +const authCache = new Map>(); // biome-ignore lint/suspicious/noExplicitAny: Better Auth instance type is plugin-specific and can't be expressed at declaration time without duplicating the full config signature export async function getAuth(env: ValidatedEnv): Promise { - const cached = authCache.get(env as object); + const cacheKey = `${env.NEON_DATABASE_URL}|${env.BETTER_AUTH_URL}`; + const cached = authCache.get(cacheKey); if (cached) return cached; + const promise = buildAuth(env).catch((err) => { + authCache.delete(cacheKey); + throw err; + }); + authCache.set(cacheKey, promise); + return promise; +} + +// biome-ignore lint/suspicious/noExplicitAny: Better Auth instance type is plugin-specific and can't be expressed at declaration time without duplicating the full config signature +async function buildAuth(env: ValidatedEnv): Promise { const appleClientSecret = await generateAppleClientSecret(env); - // Use the HTTP Neon driver — no long-lived connections inside a Worker. - const db = drizzle(neon(env.NEON_DATABASE_URL), { schema }); + const db = createConnection({ url: env.NEON_DATABASE_URL, useNeonHttp: true }); const auth = betterAuth({ baseURL: env.BETTER_AUTH_URL, @@ -53,7 +68,12 @@ export async function getAuth(env: ValidatedEnv): Promise { get: async (key: string) => env.AUTH_KV.get(key), // biome-ignore lint/complexity/useMaxParams: Better Auth secondaryStorage.set interface requires 3 params set: async (key: string, value: string, ttl?: number) => { - await env.AUTH_KV.put(key, value, ttl ? { expirationTtl: ttl } : undefined); + // KV requires a minimum expirationTtl of 60 seconds. + await env.AUTH_KV.put( + key, + value, + ttl !== undefined ? { expirationTtl: Math.max(60, ttl) } : undefined, + ); }, delete: async (key: string) => env.AUTH_KV.delete(key), } @@ -154,7 +174,6 @@ export async function getAuth(env: ValidatedEnv): Promise { trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], }); - authCache.set(env as object, auth); return auth; } diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index ef3841ac6d..377c40a64b 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -23,12 +23,24 @@ const isStandardPostgresUrl = (url: string) => { const pgPools = new Map(); -const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHttp?: boolean }) => { +export const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHttp?: boolean }) => { if (isStandardPostgresUrl(url)) { let pool = pgPools.get(url); if (!pool) { - pool = new Pool({ connectionString: url }); - pgPools.set(url, pool); + const newPool = new Pool({ + connectionString: url, + max: 5, + // idleTimeoutMillis: 0 prevents pg.Pool from calling setTimeout().unref(), + // which is not supported in the Cloudflare Workers runtime (miniflare). + idleTimeoutMillis: 0, + connectionTimeoutMillis: 10000, + }); + newPool.on('error', () => { + pgPools.delete(url); + newPool.end().catch(() => {}); + }); + pgPools.set(url, newPool); + pool = newPool; } return drizzlePg(pool, { schema }); } diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index f6e0143d91..0bd9795c0b 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -64,18 +64,21 @@ async function seedE2EUser() { .where(eq(schema.users.email, normalizedEmail)) .limit(1); + let userId: string; const existingUser = existing[0]; if (existingUser) { + userId = existingUser.id; await db .update(schema.users) .set({ passwordHash, emailVerified: true, updatedAt: new Date() }) - .where(eq(schema.users.id, existingUser.id)); - console.log(`E2E user refreshed: ${normalizedEmail} (id=${existingUser.id})`); + .where(eq(schema.users.id, userId)); + console.log(`E2E user refreshed: ${normalizedEmail} (id=${userId})`); } else { + userId = crypto.randomUUID(); const [inserted] = await db .insert(schema.users) .values({ - id: crypto.randomUUID(), + id: userId, name: 'E2E Automation', email: normalizedEmail, passwordHash, @@ -85,8 +88,28 @@ async function seedE2EUser() { role: 'USER', }) .returning(); - console.log(`E2E user created: ${normalizedEmail} (id=${inserted?.id})`); + userId = inserted?.id ?? userId; + console.log(`E2E user created: ${normalizedEmail} (id=${userId})`); } + + // Upsert the credential account row that better-auth looks up during sign-in. + // better-auth sets accountId = email for the 'credential' provider. + await db + .insert(schema.account) + .values({ + id: crypto.randomUUID(), + accountId: normalizedEmail, + providerId: 'credential', + userId, + password: passwordHash, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [schema.account.providerId, schema.account.accountId], + set: { userId, password: passwordHash, updatedAt: new Date() }, + }); + console.log(`E2E credential account upserted for: ${normalizedEmail}`); } finally { await pgClient?.end(); } diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index a3d7e913c7..d1058c53c8 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -128,6 +128,13 @@ ], "env": { "dev": { + "kv_namespaces": [ + { + "binding": "AUTH_KV", + "id": "0d0dd76cec764c81be58ae7b871b47cb", + "preview_id": "f3441ec9f4b044e6b6c6a087251e3f00" + } + ], "rate_limiting": [ { "binding": "TOKEN_RATE_LIMITER",