From 56610875c5ee14c59f8b143ba6543526c5b744a4 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 11:39:28 +0100 Subject: [PATCH 01/16] feat(api): local Postgres + wrangler dev stack for Maestro e2e Adds an isolated local API environment so Maestro e2e flows run against a Docker Postgres DB instead of the shared Neon dev DB. - docker-compose.e2e.yml: Postgres 16 on port 5435 (packrat_e2e DB) - scripts/e2e-local-init.sh: generates .dev.vars.e2e from existing .dev.vars, overriding only NEON_DATABASE_URL*, EXPO_PUBLIC_API_URL, BETTER_AUTH_URL - scripts/e2e-local-start.sh: starts Docker, runs migrations, seeds e2e user, launches wrangler dev with --env-file .dev.vars.e2e --ip 0.0.0.0 - scripts/e2e-local-stop.sh: tears down containers (--volumes for full reset) - package.json: dev:e2e, dev:e2e:init, dev:e2e:stop, dev:e2e:reset scripts - .dev.vars.e2e.example: committed template with local DB URL + placeholders - README.e2e-local.md: usage guide and architecture overview --- packages/api/.dev.vars.e2e.example | 73 +++++++++++++++++++ packages/api/.gitignore | 1 + packages/api/README.e2e-local.md | 93 +++++++++++++++++++++++++ packages/api/docker-compose.e2e.yml | 20 ++++++ packages/api/package.json | 4 ++ packages/api/scripts/e2e-local-init.sh | 67 ++++++++++++++++++ packages/api/scripts/e2e-local-start.sh | 85 ++++++++++++++++++++++ packages/api/scripts/e2e-local-stop.sh | 22 ++++++ 8 files changed, 365 insertions(+) create mode 100644 packages/api/.dev.vars.e2e.example create mode 100644 packages/api/README.e2e-local.md create mode 100644 packages/api/docker-compose.e2e.yml create mode 100755 packages/api/scripts/e2e-local-init.sh create mode 100755 packages/api/scripts/e2e-local-start.sh create mode 100755 packages/api/scripts/e2e-local-stop.sh 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..65a1509865 --- /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 + +``` +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: + +``` +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..c49145f03e --- /dev/null +++ b/packages/api/docker-compose.e2e.yml @@ -0,0 +1,20 @@ +services: + postgres-e2e: + image: postgres:16-alpine + environment: + POSTGRES_DB: packrat_e2e + POSTGRES_USER: e2e_user + POSTGRES_PASSWORD: e2e_pass + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "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..f7efeae0d7 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:stop": "bash scripts/e2e-local-stop.sh", + "dev:e2e:reset": "bash scripts/e2e-local-stop.sh --volumes && bash scripts/e2e-local-start.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..90aa2464e7 --- /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" | cut -d= -f2-)}" +E2E_PASS="${E2E_TEST_PASSWORD:-$(grep '^E2E_TEST_PASSWORD=' "$E2E_VARS" | 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." From b85ecd27c3a63d0581232a3a16b9d74652a4be4e Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 12:15:13 +0100 Subject: [PATCH 02/16] feat(ci): local Postgres + wrangler dev for Maestro e2e workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the shared Neon dev DB dependency from both e2e jobs. Each CI runner now spins up its own isolated Postgres instance (ikalnytskyi/action-setup-postgres), runs migrations, seeds the e2e user, and starts wrangler dev against the local DB before Maestro tests run. Key changes: - e2e-gate: gates on E2E_BETTER_AUTH_SECRET instead of NEON_DEV_DATABASE_URL - Both jobs: Start local Postgres → write .dev.vars → migrate → seed → start wrangler dev (background, health-checked) → build app → run Maestro - iOS: ikalnytskyi/action-setup-postgres works on macos-15 without Docker - Android: wrangler dev runs with --ip 0.0.0.0 so the emulator can reach it via 10.0.2.2 (apps/expo/lib/utils/getApiUrl.ts handles the substitution) - apps/expo/.env.local written before eas build --local to pin EXPO_PUBLIC_API_URL=http://localhost:8787, overriding the EAS preview env - Cache keys suffixed with 'local-api' to stay separate from deployed-API builds - Schema-valid stubs for AI/email/R2/OAuth vars (not exercised by master flow) - wrangler dev log uploaded as artifact on failure for easier debugging - Old 'Seed E2E test user in dev DB' steps (using NEON_DEV_DATABASE_URL) removed New required GitHub Secret: E2E_BETTER_AUTH_SECRET (32+ char random string) Optional secrets for trip-detail fidelity: WEATHER_API_KEY, OPENWEATHER_KEY Removed secret dependency: NEON_DEV_DATABASE_URL --- .github/workflows/e2e-tests.yml | 347 +++++++++++++++++++++++++------- 1 file changed, 278 insertions(+), 69 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 70d452f878..f476e0dad5 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,10 @@ 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. + E2E_DB_URL: postgres://e2e_user:e2e_pass@localhost:5432/packrat_e2e + E2E_API_URL: http://localhost:8787 jobs: e2e-gate: @@ -51,13 +49,16 @@ jobs: name: Verify E2E secrets are available env: E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + # NEON_DEV_DATABASE_URL no longer required — both jobs run a local + # Postgres instance via ikalnytskyi/action-setup-postgres. + # E2E_BETTER_AUTH_SECRET is the only crypto secret wrangler dev needs. + 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_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_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,106 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + # ── Local API stack ──────────────────────────────────────────────────── + + - name: Start local Postgres + uses: ikalnytskyi/action-setup-postgres@v6 + with: + username: e2e_user + password: e2e_pass + database: packrat_e2e + port: 5432 + + - 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@localhost:5432/packrat_e2e + NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost: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 + run: | + NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ + bun run --filter @packrat/api db:migrate + + - name: Seed E2E test user + run: | + NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ + E2E_TEST_EMAIL="${{ env.TEST_EMAIL }}" \ + E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ + 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 \ + > /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 +226,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 +255,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 +286,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 +304,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 +322,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 +341,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 +371,18 @@ 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 + 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 +397,11 @@ 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 }} 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 +414,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 @@ -345,6 +444,103 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + # ── Local API stack ──────────────────────────────────────────────────── + + - name: Start local Postgres + uses: ikalnytskyi/action-setup-postgres@v6 + with: + username: e2e_user + password: e2e_pass + database: packrat_e2e + port: 5432 + + - 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@localhost:5432/packrat_e2e + NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost: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 + run: | + NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ + bun run --filter @packrat/api db:migrate + + - name: Seed E2E test user + run: | + NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ + E2E_TEST_EMAIL="${{ env.TEST_EMAIL }}" \ + E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ + 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 \ + > /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 +563,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 +594,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 +626,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 +660,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 +688,15 @@ 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 + retention-days: 7 + + - name: Stop wrangler dev + if: always() + run: kill "${WRANGLER_PID:-}" 2>/dev/null || true From ade4c490fc50433cfa4e9023faea923f28f8fbe5 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 15:14:36 +0100 Subject: [PATCH 03/16] fix(api): local e2e stack - pgvector image, auth DB routing, credential account seed - docker-compose.e2e.yml: switch to pgvector/pgvector:pg16 so migrations that CREATE EXTENSION vector succeed against the local container - src/db/index.ts: export createConnection so callers can reuse the standard- Postgres routing logic - src/auth/index.ts: replace hardcoded neon() with createConnection so better-auth uses pg.Pool when NEON_DATABASE_URL points at localhost - src/db/seed-e2e-user.ts: upsert a credential account row (providerId= 'credential', accountId=email) that better-auth requires for sign-in --- packages/api/docker-compose.e2e.yml | 2 +- packages/api/src/auth/index.ts | 6 ++---- packages/api/src/db/index.ts | 2 +- packages/api/src/db/seed-e2e-user.ts | 31 ++++++++++++++++++++++++---- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/api/docker-compose.e2e.yml b/packages/api/docker-compose.e2e.yml index c49145f03e..139043ff97 100644 --- a/packages/api/docker-compose.e2e.yml +++ b/packages/api/docker-compose.e2e.yml @@ -1,6 +1,6 @@ services: postgres-e2e: - image: postgres:16-alpine + image: pgvector/pgvector:pg16 environment: POSTGRES_DB: packrat_e2e POSTGRES_USER: e2e_user diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index f3ce6057c1..257eac2dbe 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -9,13 +9,12 @@ 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 ───────────────────────────────────────── // biome-ignore lint/suspicious/noExplicitAny: Better Auth's generic type parameter is too specific to the exact plugin set — can't use ReturnType here @@ -28,8 +27,7 @@ export async function getAuth(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, diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index ef3841ac6d..1ba0a7c617 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -23,7 +23,7 @@ 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) { diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index f6e0143d91..ded294262d 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: { password: passwordHash, updatedAt: new Date() }, + }); + console.log(`E2E credential account upserted for: ${normalizedEmail}`); } finally { await pgClient?.end(); } From 19882345e230e1c0ab3dcdee8a005c1bc3683350 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 15:51:25 +0100 Subject: [PATCH 04/16] fix(api): set idleTimeoutMillis=0 on pg.Pool for Workers runtime compatibility pg.Pool calls setTimeout().unref() to manage idle connection cleanup. The Cloudflare Workers runtime (miniflare) does not expose .unref() on timer handles, causing an uncaught TypeError on every connection release and making all auth/DB requests fail with 500. Setting idleTimeoutMillis=0 disables the idle timer, preventing the .unref() call. Connections remain open until explicitly released or the pool is destroyed. Also adds a pool error handler to clear the cache on error so subsequent requests get a fresh pool. --- packages/api/src/db/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index 1ba0a7c617..154a02efe4 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -27,7 +27,17 @@ export const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHtt if (isStandardPostgresUrl(url)) { let pool = pgPools.get(url); if (!pool) { - pool = new Pool({ connectionString: url }); + pool = 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, + }); + pool.on('error', () => { + pgPools.delete(url); + }); pgPools.set(url, pool); } return drizzlePg(pool, { schema }); From 888ba0d1bbcfe3da5007e106c2ff4da5a591d021 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 16:46:01 +0100 Subject: [PATCH 05/16] fix(e2e): handle Expo dev-client overlays and KV TTL floor in local e2e stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Maestro login-flow: dismiss dev tools intro, dev-menu sheet, and "Error loading app" alert; handle DEVELOPMENT SERVERS picker when Metro URL is stale by tapping the discovered server row via METRO_HOST:METRO_PORT. - Maestro clear-state: replace fragile point-based Metro row tap with an explicit text tap on "http://${METRO_HOST}:${METRO_PORT}" — avoids mDNS discovery unreliability after server restarts and ensures the saved URL is the routable LAN IP (not localhost, which resolves to the simulator). - auth/index.ts: enforce Math.max(60, ttl) on KV put — Cloudflare KV rejects expirationTtl < 60, which caused sign-in to 500 when the rate limiter tried to store a 10-second window entry. --- .maestro/flows/auth/login-flow.yaml | 51 +++++++++++++++++++++++++++ .maestro/flows/setup/clear-state.yaml | 20 +++++++++++ packages/api/src/auth/index.ts | 7 +++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.maestro/flows/auth/login-flow.yaml b/.maestro/flows/auth/login-flow.yaml index 7f99fecdaa..feb9a50c01 100644 --- a/.maestro/flows/auth/login-flow.yaml +++ b/.maestro/flows/auth/login-flow.yaml @@ -17,6 +17,57 @@ appId: ${APP_ID} text: "Wait" - waitForAnimationToEnd +# Give the app time to render its initial screen before checking for overlays. +- waitForAnimationToEnd + +# 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: + visible: + text: "Error loading app" + commands: + - tapOn: + text: "OK" + - waitForAnimationToEnd + +# 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: + - 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: + visible: + text: "Continue" + commands: + - tapOn: + text: "Continue" + - waitForAnimationToEnd + - runFlow: + when: + visible: + text: "Reload" + commands: + - tapOn: + point: "50%,10%" + - waitForAnimationToEnd + # Wait for the auth entry screen — ensures sign-in button is rendered before tapping - extendedWaitUntil: visible: 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/src/auth/index.ts b/packages/api/src/auth/index.ts index 257eac2dbe..df3fcb216f 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -51,7 +51,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 ? { expirationTtl: Math.max(60, ttl) } : undefined, + ); }, delete: async (key: string) => env.AUTH_KV.delete(key), } From a0d03cb14185e472e3dd79e1a06da6e4714d767b Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 16:46:17 +0100 Subject: [PATCH 06/16] chore: sort packages/api/package.json keys --- packages/api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index f7efeae0d7..8cd17032ee 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -27,8 +27,8 @@ "dev": "wrangler dev -e=dev", "dev:e2e": "bash scripts/e2e-local-start.sh", "dev:e2e:init": "bash scripts/e2e-local-init.sh", - "dev:e2e:stop": "bash scripts/e2e-local-stop.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", From d29f4f3c76230a6e3245513210989b243c528cbf Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 18:25:21 +0100 Subject: [PATCH 07/16] fix(api): reuse auth instance across requests in miniflare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change authCache from WeakMap to Map keyed by NEON_DATABASE_URL. Miniflare creates a new env object per request so the WeakMap never hit — every request re-initialized Better Auth and queried the JWKS table, exhausting the pg.Pool after a handful of calls. --- packages/api/src/auth/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index df3fcb216f..0c3a725cc0 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -3,7 +3,7 @@ * * 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. */ @@ -17,12 +17,15 @@ import { betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; // ─── Per-isolate auth instance cache ───────────────────────────────────────── +// Keyed by NEON_DATABASE_URL so the same instance is reused across requests +// within the same isolate — miniflare creates a new env object per request, +// so a WeakMap would never hit and every request would re-initialize auth. // 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 cached = authCache.get(env.NEON_DATABASE_URL); if (cached) return cached; const appleClientSecret = await generateAppleClientSecret(env); @@ -157,7 +160,7 @@ export async function getAuth(env: ValidatedEnv): Promise { trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], }); - authCache.set(env as object, auth); + authCache.set(env.NEON_DATABASE_URL, auth); return auth; } From 04aa314779ea9087ef914f14c1d802ae9f5b85bf Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 15:09:29 +0100 Subject: [PATCH 08/16] fix(ci): fix e2e pgvector extension and iOS postgres setup failures Android: replace action-setup-postgres step with a job-level Docker service using pgvector/pgvector:pg16, which ships PostgreSQL 16 + the vector extension pre-installed. This removes the "extension not available" error on CREATE EXTENSION vector. iOS: replace action-setup-postgres@v6 (exit-127 on macos-15-arm64, likely broken pg_ctl PATH with keg-only postgresql@14) with an explicit brew-based step: installs postgresql@16 + pgvector, runs initdb/pg_ctl with full binary paths, and creates the e2e role/database directly. --- .github/workflows/e2e-tests.yml | 53 +++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f476e0dad5..70f72e6a7f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -101,13 +101,28 @@ jobs: # ── Local API stack ──────────────────────────────────────────────────── - - name: Start local Postgres - uses: ikalnytskyi/action-setup-postgres@v6 - with: - username: e2e_user - password: e2e_pass - database: packrat_e2e - port: 5432 + - 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" + + brew install "postgresql@${PG_VER}" pgvector + + "$PG_BIN/initdb" -D "$PG_DATA" --encoding=UTF8 --auth=trust + printf 'port = 5432\nlisten_addresses = '"'"'127.0.0.1'"'"'\n' \ + >> "$PG_DATA/postgresql.conf" + "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$HOME/pg.log" start + + for i in $(seq 1 30); do + "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 && break || sleep 2 + done + + "$PG_BIN/psql" -h 127.0.0.1 -p 5432 -U "$(whoami)" postgres \ + -c "CREATE USER e2e_user WITH PASSWORD 'e2e_pass';" + "$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: @@ -400,6 +415,21 @@ jobs: 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 run: | @@ -445,14 +475,7 @@ jobs: run: bun install --frozen-lockfile # ── Local API stack ──────────────────────────────────────────────────── - - - name: Start local Postgres - uses: ikalnytskyi/action-setup-postgres@v6 - with: - username: e2e_user - password: e2e_pass - database: packrat_e2e - port: 5432 + # Postgres with pgvector runs as a job-level service container (see services: above). - name: Write wrangler .dev.vars env: From 59d495143904721963316cfe6da82e07514ff4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 14:48:02 +0000 Subject: [PATCH 09/16] fix(ci): fix wrangler PATH and pgvector build for e2e local API stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented the local API stack steps from passing: 1. wrangler not in PATH — both jobs called `wrangler dev` directly, but after `bun install` the binary lives in `node_modules/.bin` which GitHub Actions does not add to PATH automatically. Fix: new step `Add node_modules bin to PATH` appends $GITHUB_WORKSPACE/node_modules/.bin to GITHUB_PATH immediately after `bun install` in both jobs. 2. pgvector not available for postgresql@16 on macOS — `brew install pgvector` targets whichever PostgreSQL version its formula depends on (typically the latest), which may not be @16. When migrations run `CREATE EXTENSION vector` the extension was not found. Fix: replace `brew install pgvector` with an explicit source build using postgresql@16's own pg_config so the extension files land in the right place. Also adds a safety call to stop any pre-installed postgres service before starting ours on port 5432, and replaces the silent pg_isready loop with one that exits loudly on timeout. https://claude.ai/code/session_018z2eBXirwsi5dujqezRk7Y --- .github/workflows/e2e-tests.yml | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 70f72e6a7f..1768dbd4f8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -99,6 +99,9 @@ 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 @@ -108,16 +111,37 @@ jobs: PG_BIN="${BREW_PREFIX}/opt/postgresql@${PG_VER}/bin" PG_DATA="$HOME/pgdata-e2e" - brew install "postgresql@${PG_VER}" pgvector + # Free port 5432 in case a pre-installed postgres is running + brew services stop postgresql@14 2>/dev/null || true + brew services stop "postgresql@${PG_VER}" 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 'port = 5432\nlisten_addresses = '"'"'127.0.0.1'"'"'\n' \ + 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 - "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 && break || sleep 2 + 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';" @@ -474,6 +498,9 @@ 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). From 02a25aa291eafe38ef04c9a910d638d364c72287 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 19:26:15 +0100 Subject: [PATCH 10/16] fix(e2e): force wrangler dev into local mode and fix kv_namespaces env.dev gap Wrangler 4.x was attempting a remote proxy session (failing with "must be logged in") because --local was never passed. Add --local to both the iOS and Android wrangler dev invocations so CI never needs Cloudflare auth. Also copy kv_namespaces into env.dev in wrangler.jsonc: Wrangler does not inherit top-level bindings into named environments, so AUTH_KV was missing when running `-e dev`, causing a binding error at startup. --- .github/workflows/e2e-tests.yml | 4 +- .maestro/flows/auth/login-flow.yaml | 176 +++++++++++---------- .maestro/flows/packs/create-pack-flow.yaml | 10 +- packages/api/wrangler.jsonc | 7 + 4 files changed, 109 insertions(+), 88 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1768dbd4f8..067890d13e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -219,7 +219,7 @@ jobs: run: | WRANGLER_LOG=warn \ WRANGLER_SEND_METRICS=false \ - wrangler dev -e dev --ip 0.0.0.0 \ + wrangler dev -e dev --ip 0.0.0.0 --local \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" @@ -572,7 +572,7 @@ jobs: # 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" diff --git a/.maestro/flows/auth/login-flow.yaml b/.maestro/flows/auth/login-flow.yaml index feb9a50c01..6ceda2160b 100644 --- a/.maestro/flows/auth/login-flow.yaml +++ b/.maestro/flows/auth/login-flow.yaml @@ -68,104 +68,114 @@ appId: ${APP_ID} point: "50%,10%" - 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. +# 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: - 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 + visible: + id: "sign-in-email-button" commands: + # Tap the "Sign In" (email) button to navigate to the email login screen - tapOn: - id: "email-input" - - inputText: "${TEST_EMAIL}" + id: "sign-in-email-button" -# 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} + # 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 -# Dismiss the keyboard before submitting -- hideKeyboard + # 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}" -# Submit login form via the continue button -- tapOn: - id: "continue-button" + # 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} -# Wait for navigation to complete — login can take a few seconds -- waitForAnimationToEnd + # Dismiss the keyboard before submitting + - hideKeyboard -# Handle transient network error: if the API call failed, dismiss the alert and retry once. -- runFlow: - when: - visible: - text: "Network request failed" - commands: - - tapOn: - text: "OK" - - waitForAnimationToEnd + # 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/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", From d94e882a8df0ba6364545bcbafe19fd325184969 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 19:35:02 +0100 Subject: [PATCH 11/16] fix(ci): fix pgvector superuser permission on iOS and Node.js version on Android iOS: e2e_user was created without SUPERUSER so `CREATE EXTENSION vector` in the migration was denied. Grant SUPERUSER at user-creation time. Android: wrangler 4.x requires Node.js >= 22 but ubuntu-latest ships v20. Add setup-node@v4 with node-version: '22' before wrangler dev is invoked. --- .github/workflows/e2e-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 067890d13e..28ae099e55 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -144,7 +144,7 @@ jobs: fi "$PG_BIN/psql" -h 127.0.0.1 -p 5432 -U "$(whoami)" postgres \ - -c "CREATE USER e2e_user WITH PASSWORD 'e2e_pass';" + -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;" @@ -480,6 +480,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: From 3285a9e3ae196ce9025c08abc693d6931ac0605f Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 19:41:13 +0100 Subject: [PATCH 12/16] =?UTF-8?q?fix(ci):=20disable=20wrangler=20container?= =?UTF-8?q?s=20in=20e2e=20=E2=80=94=20Docker=20not=20available=20on=20CI?= =?UTF-8?q?=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/e2e-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 28ae099e55..38ebffff10 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -219,7 +219,7 @@ jobs: run: | WRANGLER_LOG=warn \ WRANGLER_SEND_METRICS=false \ - wrangler dev -e dev --ip 0.0.0.0 --local \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" @@ -577,7 +577,7 @@ jobs: # 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" From f9e01cd970199f8b5272b29ba2f921350c965152 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 19:46:26 +0100 Subject: [PATCH 13/16] fix(ci): install Docker via Colima on macOS runner for wrangler container support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS-15 runners don't ship Docker. Colima provides a lightweight Docker- compatible daemon. Ubuntu runners already have Docker so the Android job needed no change — just removed the --enable-containers=false flag both jobs had from the previous (wrong) fix. --- .github/workflows/e2e-tests.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 38ebffff10..c02f9e2f85 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -214,12 +214,19 @@ jobs: E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ bun run --filter @packrat/api db:seed:e2e-user + - name: Install Docker via Colima + run: | + brew install colima docker + colima start --cpu 2 --memory 4 --disk 20 + until docker info >/dev/null 2>&1; do sleep 1; done + echo "Docker ready" + - 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" @@ -577,7 +584,7 @@ jobs: # 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" From e4b28d5c624bb2206e0c6bcf0a26181609f74e00 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 20:03:52 +0100 Subject: [PATCH 14/16] =?UTF-8?q?fix(ci):=20use=20QEMU=20backend=20for=20C?= =?UTF-8?q?olima=20=E2=80=94=20VZ=20is=20blocked=20on=20macOS-15=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Virtualization.framework (VZ) requires the com.apple.security.hypervisor entitlement which the GitHub Actions macOS-15 sandbox doesn't grant. Switching to --vm-type=qemu (which uses software emulation) avoids this restriction. --- .github/workflows/e2e-tests.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c02f9e2f85..3f097c12d4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -214,10 +214,12 @@ jobs: E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ bun run --filter @packrat/api db:seed:e2e-user - - name: Install Docker via Colima + - name: Install Docker via Colima (QEMU backend) run: | - brew install colima docker - colima start --cpu 2 --memory 4 --disk 20 + brew install colima docker qemu + # VZ (Apple Virtualization.framework) is blocked in the GitHub Actions + # macOS-15 sandbox. Force the QEMU backend which has no such restriction. + colima start --vm-type=qemu --cpu 2 --memory 4 --disk 20 until docker info >/dev/null 2>&1; do sleep 1; done echo "Docker ready" From a97207da71c5d1b5bd42e556a8cb61ab1cbcba5d Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 20:12:54 +0100 Subject: [PATCH 15/16] =?UTF-8?q?fix(ci):=20disable=20containers=20on=20iO?= =?UTF-8?q?S=20job=20=E2=80=94=20macOS=20runners=20have=20no=20hypervisor?= =?UTF-8?q?=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both VZ (Virtualization.framework) and QEMU/HVF require entitlements that the GitHub Actions macOS-15 sandbox does not grant. Neither Colima backend can boot a VM, so Docker is unavailable on macos runners. The Maestro flows test login + pack creation, not the AppContainer (TikTok scraper) endpoint. The Android job on ubuntu-latest retains full container support since Docker is natively available there. --- .github/workflows/e2e-tests.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3f097c12d4..25d9b6f159 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -214,21 +214,12 @@ jobs: E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ bun run --filter @packrat/api db:seed:e2e-user - - name: Install Docker via Colima (QEMU backend) - run: | - brew install colima docker qemu - # VZ (Apple Virtualization.framework) is blocked in the GitHub Actions - # macOS-15 sandbox. Force the QEMU backend which has no such restriction. - colima start --vm-type=qemu --cpu 2 --memory 4 --disk 20 - until docker info >/dev/null 2>&1; do sleep 1; done - echo "Docker ready" - - 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" From 0af518e05106a73f6fe39631a2bdd95da6be1694 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 21:22:16 +0100 Subject: [PATCH 16/16] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow (.github/workflows/e2e-tests.yml): - e2e-gate now checks E2E_TEST_PASSWORD in addition to EMAIL + AUTH_SECRET so the gate skips rather than fails when the password secret is missing - Stop all brew Postgres services generically (not just @14/@16) to reliably free port 5432 regardless of formula version on the runner - Use 127.0.0.1 in E2E_DB_URL and .dev.vars heredocs — `localhost` can resolve to ::1 (IPv6) when Postgres only listens on 127.0.0.1 (IPv4) - Move NEON_DATABASE_URL / E2E_TEST_* from inline shell to env: blocks on both migration and seed steps (avoids injection from shell metacharacters) - Add --enable-containers=false to Android wrangler dev (consistent with iOS; prevents Miniflare from hanging trying to start containers it can't reach) - Add if-no-files-found: ignore on both wrangler-dev-log artifact uploads so a Postgres/migration failure earlier in the job doesn't hide the real error packages/api/src/auth/index.ts: - Cache the in-flight Promise (not just the settled instance) so concurrent requests don't each spin up a redundant Better Auth build; evict on rejection - Composite cache key (NEON_DATABASE_URL|BETTER_AUTH_URL) to correctly distinguish env configurations that share a DB URL - Fix ttl === 0 being treated as "no TTL" due to truthy check; use !== undefined packages/api/src/db/index.ts: - Call newPool.end() in the pool error handler before removing it from the cache to avoid leaking connections and sockets after an idle/fatal error packages/api/src/db/seed-e2e-user.ts: - Include userId in the account upsert conflict set so a stale credential row pointing at an old user ID is corrected on re-seed packages/api/docker-compose.e2e.yml: - Remove POSTGRES_HOST_AUTH_METHOD: trust (unnecessary with PASSWORD set) - Bind published port to 127.0.0.1 to avoid unintended LAN exposure packages/api/scripts/e2e-local-start.sh: - Guard grep with 2>/dev/null || true so set -euo pipefail doesn't abort the script when keys are absent; fallback defaults remain reachable packages/api/README.e2e-local.md: - Add language identifiers to unlabelled fenced code blocks (MD040) --- .github/workflows/e2e-tests.yml | 62 +++++++++++++------------ packages/api/README.e2e-local.md | 4 +- packages/api/docker-compose.e2e.yml | 3 +- packages/api/scripts/e2e-local-start.sh | 4 +- packages/api/src/auth/index.ts | 27 ++++++++--- packages/api/src/db/index.ts | 8 ++-- packages/api/src/db/seed-e2e-user.ts | 2 +- 7 files changed, 63 insertions(+), 47 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 25d9b6f159..cc76a5ebb4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -34,7 +34,9 @@ env: MAESTRO_VERSION: 2.3.0 MAESTRO_CLI_NO_ANALYTICS: "true" # Local Postgres used by both jobs — no cloud DB dependency. - E2E_DB_URL: postgres://e2e_user:e2e_pass@localhost:5432/packrat_e2e + # 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: @@ -49,16 +51,14 @@ jobs: name: Verify E2E secrets are available env: E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} - # NEON_DEV_DATABASE_URL no longer required — both jobs run a local - # Postgres instance via ikalnytskyi/action-setup-postgres. - # E2E_BETTER_AUTH_SECRET is the only crypto secret wrangler dev needs. + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} run: | - if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$E2E_BETTER_AUTH_SECRET" ]; 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 (need E2E_TEST_EMAIL + E2E_BETTER_AUTH_SECRET)" + echo "::notice::E2E secrets not configured — skipping E2E tests (need E2E_TEST_EMAIL + E2E_TEST_PASSWORD + E2E_BETTER_AUTH_SECRET)" fi ios-e2e: @@ -111,9 +111,9 @@ jobs: PG_BIN="${BREW_PREFIX}/opt/postgresql@${PG_VER}/bin" PG_DATA="$HOME/pgdata-e2e" - # Free port 5432 in case a pre-installed postgres is running - brew services stop postgresql@14 2>/dev/null || true - brew services stop "postgresql@${PG_VER}" 2>/dev/null || true + # 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}" @@ -160,8 +160,8 @@ jobs: # 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@localhost:5432/packrat_e2e - NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost:5432/packrat_e2e + 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 @@ -203,16 +203,16 @@ jobs: >> packages/api/.dev.vars - name: Run DB migrations - run: | - NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ - bun run --filter @packrat/api db:migrate + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + run: bun run --filter @packrat/api db:migrate - name: Seed E2E test user - run: | - NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ - E2E_TEST_EMAIL="${{ env.TEST_EMAIL }}" \ - E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ - bun run --filter @packrat/api db:seed:e2e-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 @@ -416,6 +416,7 @@ jobs: with: name: ios-wrangler-dev-log path: /tmp/wrangler-dev.log + if-no-files-found: ignore retention-days: 7 - name: Stop wrangler dev @@ -516,8 +517,8 @@ jobs: OPENWEATHER_KEY: ${{ secrets.OPENWEATHER_KEY }} run: | cat > packages/api/.dev.vars << 'DEVVARS' - NEON_DATABASE_URL=postgres://e2e_user:e2e_pass@localhost:5432/packrat_e2e - NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost:5432/packrat_e2e + 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 @@ -558,16 +559,16 @@ jobs: >> packages/api/.dev.vars - name: Run DB migrations - run: | - NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ - bun run --filter @packrat/api db:migrate + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_URL }} + run: bun run --filter @packrat/api db:migrate - name: Seed E2E test user - run: | - NEON_DATABASE_URL="${{ env.E2E_DB_URL }}" \ - E2E_TEST_EMAIL="${{ env.TEST_EMAIL }}" \ - E2E_TEST_PASSWORD="${{ env.TEST_PASSWORD }}" \ - bun run --filter @packrat/api db:seed:e2e-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 @@ -577,7 +578,7 @@ jobs: # 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 \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ > /tmp/wrangler-dev.log 2>&1 & echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" @@ -750,6 +751,7 @@ jobs: with: name: android-wrangler-dev-log path: /tmp/wrangler-dev.log + if-no-files-found: ignore retention-days: 7 - name: Stop wrangler dev diff --git a/packages/api/README.e2e-local.md b/packages/api/README.e2e-local.md index 65a1509865..f001af0b89 100644 --- a/packages/api/README.e2e-local.md +++ b/packages/api/README.e2e-local.md @@ -27,7 +27,7 @@ The API is now live at **http://localhost:8787**. ## How the stack connects -``` +```text iOS Simulator ──────► localhost:8787 (wrangler dev) │ ▼ @@ -38,7 +38,7 @@ 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 ``` diff --git a/packages/api/docker-compose.e2e.yml b/packages/api/docker-compose.e2e.yml index 139043ff97..b7a5022beb 100644 --- a/packages/api/docker-compose.e2e.yml +++ b/packages/api/docker-compose.e2e.yml @@ -5,9 +5,8 @@ services: POSTGRES_DB: packrat_e2e POSTGRES_USER: e2e_user POSTGRES_PASSWORD: e2e_pass - POSTGRES_HOST_AUTH_METHOD: trust ports: - - "5435:5432" + - "127.0.0.1:5435:5432" volumes: - postgres_e2e_data:/var/lib/postgresql/data healthcheck: diff --git a/packages/api/scripts/e2e-local-start.sh b/packages/api/scripts/e2e-local-start.sh index 90aa2464e7..98605e6ef3 100755 --- a/packages/api/scripts/e2e-local-start.sh +++ b/packages/api/scripts/e2e-local-start.sh @@ -56,8 +56,8 @@ echo "▶ Running schema migrations..." ) # ── Seed E2E user ──────────────────────────────────────────────────────────── -E2E_EMAIL="${E2E_TEST_EMAIL:-$(grep '^E2E_TEST_EMAIL=' "$E2E_VARS" | cut -d= -f2-)}" -E2E_PASS="${E2E_TEST_PASSWORD:-$(grep '^E2E_TEST_PASSWORD=' "$E2E_VARS" | cut -d= -f2-)}" +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!}" diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 0c3a725cc0..2496f4e311 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -17,17 +17,31 @@ import { betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; // ─── Per-isolate auth instance cache ───────────────────────────────────────── -// Keyed by NEON_DATABASE_URL so the same instance is reused across requests -// within the same isolate — miniflare creates a new env object per request, -// so a WeakMap would never hit and every request would re-initialize auth. +// 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 Map(); +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.NEON_DATABASE_URL); + 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); const db = createConnection({ url: env.NEON_DATABASE_URL, useNeonHttp: true }); @@ -58,7 +72,7 @@ export async function getAuth(env: ValidatedEnv): Promise { await env.AUTH_KV.put( key, value, - ttl ? { expirationTtl: Math.max(60, ttl) } : undefined, + ttl !== undefined ? { expirationTtl: Math.max(60, ttl) } : undefined, ); }, delete: async (key: string) => env.AUTH_KV.delete(key), @@ -160,7 +174,6 @@ export async function getAuth(env: ValidatedEnv): Promise { trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], }); - authCache.set(env.NEON_DATABASE_URL, auth); return auth; } diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index 154a02efe4..377c40a64b 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -27,7 +27,7 @@ export const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHtt if (isStandardPostgresUrl(url)) { let pool = pgPools.get(url); if (!pool) { - pool = new Pool({ + const newPool = new Pool({ connectionString: url, max: 5, // idleTimeoutMillis: 0 prevents pg.Pool from calling setTimeout().unref(), @@ -35,10 +35,12 @@ export const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHtt idleTimeoutMillis: 0, connectionTimeoutMillis: 10000, }); - pool.on('error', () => { + newPool.on('error', () => { pgPools.delete(url); + newPool.end().catch(() => {}); }); - pgPools.set(url, pool); + 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 ded294262d..0bd9795c0b 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -107,7 +107,7 @@ async function seedE2EUser() { }) .onConflictDoUpdate({ target: [schema.account.providerId, schema.account.accountId], - set: { password: passwordHash, updatedAt: new Date() }, + set: { userId, password: passwordHash, updatedAt: new Date() }, }); console.log(`E2E credential account upserted for: ${normalizedEmail}`); } finally {