From 1f5f20c0c813c379fb4f32b0064904f01c6a0be9 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 09:53:42 -0600 Subject: [PATCH 1/4] ci(web-e2e): run against a local API + Neon proxy instead of the deployed API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web E2E has been failing on development for days: globalSetup signs in via a cross-origin fetch to the *deployed* API (EXPO_PUBLIC_API_URL), which returns 'Failed to fetch' (status 0) through all 8 retries — the deployed dev API is 5xx-ing / unreachable from CI. The code comment already assumed 'a local wrangler dev worker', but the workflow never stood one up. This wires the workflow to the local stack that already exists for local dev (docker-compose.test.yml + maybeConfigureLocalNeon + .dev.vars.e2e.example): - start Postgres + the Neon HTTP/WS proxy (:4444) via docker-compose.test.yml - migrate + seed the e2e user into the local DB (raw pg on :5433) - boot the API via 'wrangler dev' on :8787 with a generated .dev.vars (real URLs + dummy-but-schema-valid stand-ins for AI/email/storage keys) - build the web app against http://localhost:8787 and serve it on :8081 - point Playwright (API_URL/BASE_URL) at the local stack CORS already allows localhost, so sign-in works against the local worker with no dependency on deployed-API health or cross-origin config. NOTE: first CI iteration — the open question is whether 'wrangler dev' boots cleanly given the container-backed AppContainer Durable Object (image: ./Dockerfile); logs are captured (Dump API log on failure) to debug. May also need E2E_BETTER_AUTH_SECRET / E2E_JWT_SECRET repo secrets (falls back to placeholders). --- .github/workflows/web-e2e-tests.yml | 119 ++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index ee58cc728d..f450c90354 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -9,6 +9,7 @@ on: - "!apps/expo/**/*.test.ts" - "!apps/expo/**/*.test.tsx" - "!apps/expo/vitest.config.ts" + - "packages/api/**" - ".github/workflows/web-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 @@ -21,6 +22,7 @@ on: - "!apps/expo/**/*.test.ts" - "!apps/expo/**/*.test.tsx" - "!apps/expo/vitest.config.ts" + - "packages/api/**" - ".github/workflows/web-e2e-tests.yml" workflow_dispatch: @@ -43,9 +45,10 @@ jobs: name: Verify E2E secrets are available env: E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} run: | - if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then + # The local stack supplies its own DB; only the seeded test-user + # credentials are required from secrets. + if [ -n "$E2E_TEST_EMAIL" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" @@ -60,10 +63,17 @@ jobs: timeout-minutes: 30 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. + # The seeded e2e user. The local stack auths against the local DB, so there + # is no dependency on the deployed API being reachable / CORS-open. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + # The whole stack runs on localhost: a local Neon HTTP proxy in front of a + # Docker Postgres, a `wrangler dev` API worker, and the served web SPA. + # db.localtest.me resolves to 127.0.0.1 via public wildcard DNS; the API's + # maybeConfigureLocalNeon() routes the neon driver to the proxy on :4444. + NEON_DATABASE_URL: postgres://test_user:test_password@db.localtest.me/packrat_test + NEON_DATABASE_URL_READONLY: postgres://test_user:test_password@db.localtest.me/packrat_test + API_PORT: "8787" steps: - name: Checkout repository @@ -91,26 +101,100 @@ jobs: - name: Install Playwright browsers run: bunx playwright install chromium --with-deps - - name: Build Expo web app + # ── Local DB stack: Postgres + Neon HTTP/WS proxy on :4444 ────────────── + - name: Start local Neon proxy + Postgres + working-directory: packages/api + env: + POSTGRES_TEST_HOST_PORT: "5433" + NEON_PROXY_HOST_PORT: "4444" + run: | + docker compose -p packrat-e2e -f docker-compose.test.yml up -d --wait + echo "Stack up. Containers:" + docker compose -p packrat-e2e -f docker-compose.test.yml ps + + # Node-side scripts (migrate/seed) can't use the worker-only + # db.localtest.me neon-proxy routing, so they hit raw Postgres on :5433. + # The worker (.dev.vars below) uses db.localtest.me → proxy :4444. + - name: Run migrations + seed e2e user (local DB) + env: + NEON_DATABASE_URL: postgres://test_user:test_password@localhost:5433/packrat_test + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }} + run: | + bun run --filter @packrat/api db:migrate + bun run --filter @packrat/api db:seed:e2e-user + + # ── Write the worker's .dev.vars: real URLs + dummy-but-valid stand-ins ── + # env-validation.ts requires ~25 keys to boot; sign-in only exercises the + # DB + Better Auth, so AI/email/maps/storage keys are placeholder values + # that satisfy the schema's format constraints (sk-/pplx- prefixes, urls, + # emails) without calling those services. + - name: Write API .dev.vars for E2E + working-directory: packages/api + run: | + cat > .dev.vars < /tmp/wrangler.log 2>&1 & + echo "Waiting for API on http://localhost:${API_PORT} ..." + for i in $(seq 1 60); do + if curl -sf "http://localhost:${API_PORT}/api/health" >/dev/null 2>&1 \ + || curl -sf "http://localhost:${API_PORT}/" >/dev/null 2>&1; then + echo "API ready"; exit 0 + fi + sleep 2 + done + echo "::error::API did not become ready — wrangler log:"; cat /tmp/wrangler.log; exit 1 + + - name: Build Expo web app (against local API) working-directory: apps/expo env: - EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + EXPO_PUBLIC_API_URL: http://localhost:8787 EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: ${{ secrets.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID }} EXPO_PUBLIC_R2_PUBLIC_URL: ${{ secrets.EXPO_PUBLIC_R2_PUBLIC_URL }} EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY }} run: bunx expo export -p web --output-dir dist - - 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: Serve web app (SPA mode, port 8081) working-directory: apps/expo - # -s routes all 404s to index.html for client-side routing run: npx serve -s dist -l 8081 & - name: Wait for web server @@ -125,11 +209,14 @@ jobs: working-directory: apps/expo env: BASE_URL: http://localhost:8081 - API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + API_URL: http://localhost:8787 CI: "true" run: bun test:web + - name: Dump API log on failure + if: failure() + run: tail -200 /tmp/wrangler.log || true + - name: Upload Playwright report on failure if: failure() uses: actions/upload-artifact@v7 From ff2e974366064787acba35c20f6d87be3da6d224 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 10:09:15 -0600 Subject: [PATCH 2/4] ci(web-e2e): strip remote AI binding so wrangler dev boots local in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CI run got the full stack up (Postgres + Neon proxy + worker env all loaded) but wrangler dev failed: the Workers AI binding runs in 'remote' mode (no local emulator), so wrangler opened a remote-proxy session requiring CF login. Sign-in never uses AI, and env-validation's AI: z.unknown() tolerates it absent, so generate an AI-stripped wrangler.e2e.json for the run → no remote bindings → boots fully local, no CF auth needed. (The container-backed AppContainer DO booted fine — wrangler only lists it, doesn't build at startup.) --- .github/workflows/web-e2e-tests.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index f450c90354..ce5ce88a1a 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -167,12 +167,28 @@ jobs: EOF echo "Wrote .dev.vars ($(wc -l < .dev.vars) keys)" + # The Workers AI binding has no local emulator, so `wrangler dev` opens a + # remote-proxy session (needs CF login) at boot — which fails in CI. Sign-in + # doesn't use AI, and env-validation's `AI: z.unknown()` tolerates it being + # absent, so strip the AI binding for the E2E run → fully-local, no auth. + - name: Generate E2E wrangler config (no remote AI binding) + working-directory: packages/api + run: | + bun -e " + const c = require('./wrangler.jsonc'); + delete c.ai; + if (c.env && c.env.dev) delete c.env.dev.ai; + require('node:fs').writeFileSync('./wrangler.e2e.json', JSON.stringify(c, null, 2)); + " + echo "Generated wrangler.e2e.json (AI binding removed)" + # ── Start the API worker (wrangler dev) and wait for health ───────────── - name: Start API (wrangler dev) on localhost working-directory: packages/api run: | # Background the worker; capture logs so failures are debuggable. - bun run dev --port "${API_PORT}" --ip 127.0.0.1 > /tmp/wrangler.log 2>&1 & + bunx wrangler dev --config wrangler.e2e.json -e dev \ + --port "${API_PORT}" --ip 127.0.0.1 > /tmp/wrangler.log 2>&1 & echo "Waiting for API on http://localhost:${API_PORT} ..." for i in $(seq 1 60); do if curl -sf "http://localhost:${API_PORT}/api/health" >/dev/null 2>&1 \ From 56a61d79862604190302f69ef92581e9a7bc35df Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 10:19:49 -0600 Subject: [PATCH 3/4] ci(web-e2e): add EMAIL_PROVIDER + AI_PROVIDER to .dev.vars Iteration 2 reached the actual sign-in: the local worker booted and responded, but POST /api/auth/sign-in/email 500'd because the per-request getEnv() validation found EMAIL_PROVIDER and AI_PROVIDER missing (required enums). Both are in .dev.vars.e2e.example; they were omitted from the generated vars. Added EMAIL_PROVIDER=resend and AI_PROVIDER=openai. --- .github/workflows/web-e2e-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index ce5ce88a1a..0ac32b0fad 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -147,8 +147,10 @@ jobs: ADMIN_USERNAME=admin ADMIN_PASSWORD=e2e-admin PACKRAT_API_KEY=e2e-api-key + EMAIL_PROVIDER=resend RESEND_API_KEY=e2e-resend-key EMAIL_FROM=no-reply@packrattest.local + AI_PROVIDER=openai OPENAI_API_KEY=sk-e2e-placeholder GOOGLE_GENERATIVE_AI_API_KEY=e2e-google-ai PERPLEXITY_API_KEY=pplx-e2e-placeholder From 5cf3ac3fe67b23d34885d541157bbcf3a1df534a Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 10:30:34 -0600 Subject: [PATCH 4/4] ci(web-e2e): set ENVIRONMENT=development so Better Auth trusts localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 3 got past env-validation; sign-in now 403'd with 'Invalid origin: http://localhost:8081'. Elysia CORS allows localhost (OPTIONS 204), but Better Auth has its own trustedOrigins check that only adds 'http://localhost:*' when env.ENVIRONMENT === 'development' (auth/index.ts:207) — and the workflow never set ENVIRONMENT. Add ENVIRONMENT=development to .dev.vars. (The Apple client-secret pkcs8 warning from the dummy key is harmless — web OAuth only.) --- .github/workflows/web-e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index 0ac32b0fad..3cece3b66b 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -133,6 +133,7 @@ jobs: working-directory: packages/api run: | cat > .dev.vars <