From b71117e4ca067a2360c8e15d07b9735caf4ebcc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 23:58:01 +0100 Subject: [PATCH 1/7] Add PostgreSQL + BRouter to CI E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI now runs the full stack for E2E tests: - PostgreSQL as a service container + schema push via drizzle - BRouter JAR + Berlin segment (E10_N50) with caching - Both cached across runs (BRouter JAR + segment) Removed all skip/workaround logic from tests — all 20 E2E tests now run in CI with real services. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 71 +++++++++++++++++++++++++++++++++++++++- e2e/integration.test.ts | 48 ++------------------------- e2e/planner.test.ts | 12 ------- 3 files changed, 73 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 253a052..6c07ab8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,22 @@ jobs: name: E2E Tests needs: build runs-on: ubuntu-latest + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_USER: trails + POSTGRES_PASSWORD: trails + POSTGRES_DB: trails + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U trails" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 @@ -75,8 +91,61 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + + - name: Push database schema + run: pnpm db:push + + - name: Build and cache BRouter + id: brouter-cache + uses: actions/cache@v4 + with: + path: /tmp/brouter + key: brouter-1.7.8 + + - name: Download BRouter + if: steps.brouter-cache.outputs.cache-hit != 'true' + run: | + mkdir -p /tmp/brouter + wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip + cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip + + - name: Cache BRouter segment + id: segment-cache + uses: actions/cache@v4 + with: + path: /tmp/brouter-segments + key: brouter-segment-E10_N50 + + - name: Download Berlin segment + if: steps.segment-cache.outputs.cache-hit != 'true' + run: | + mkdir -p /tmp/brouter-segments + wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 + + - name: Start BRouter + run: | + cd /tmp/brouter + java -Xmx256M -Xms64M \ + -DmaxRunningTime=300 \ + -cp brouter-1.7.8-all.jar \ + btools.server.RouteServer \ + /tmp/brouter-segments profiles2 profiles2 \ + 17777 2 & + # Wait for BRouter to start + for i in $(seq 1 30); do + curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break + sleep 2 + done + env: + BROUTER_URL: http://localhost:17777 + - run: pnpm exec playwright install --with-deps chromium - - run: pnpm test:e2e + + - name: Run E2E tests + run: pnpm test:e2e + env: + BROUTER_URL: http://localhost:17777 + - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index 3cd00bc..54c904b 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -2,38 +2,17 @@ import { test, expect } from "@playwright/test"; /** * Integration tests that require the full dev stack: - * - PostgreSQL (for auth and routes) + * - PostgreSQL (for sessions) * - BRouter (for route computation) * - * Run with: pnpm dev:full (in another terminal), then pnpm test:e2e - * These tests are skipped in CI unless services are available. + * In CI, these services are started by the workflow. + * Locally, run `pnpm dev:full` first. */ -const JOURNAL = "http://localhost:3000"; const PLANNER = "http://localhost:3001"; -// Helper: check if DB is available (checks Planner API) -async function isDbAvailable(): Promise { - try { - const resp = await fetch(`${PLANNER}/api/sessions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - return resp.ok; // 201 = DB available, 503 = DB unavailable - } catch { - return false; - } -} - test.describe("Integration: Journal ↔ Planner handoff", () => { - test.beforeAll(async () => { - const dbAvailable = await isDbAvailable(); - test.skip(!dbAvailable, "Database not available — run pnpm dev:full"); - }); - test("GPX import → view route → export GPX", async ({ request }) => { - // This tests the API flow without needing WebAuthn const gpx = ` Berlin @@ -45,7 +24,6 @@ test.describe("Integration: Journal ↔ Planner handoff", () => { `; - // Create a planner session with GPX const sessionResp = await request.post(`${PLANNER}/api/sessions`, { data: { gpx }, }); @@ -57,25 +35,6 @@ test.describe("Integration: Journal ↔ Planner handoff", () => { }); test.describe("Integration: BRouter routing", () => { - test.beforeAll(async () => { - try { - const resp = await fetch(`${PLANNER}/api/route`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - waypoints: [ - { lat: 52.516, lon: 13.377 }, - { lat: 52.515, lon: 13.351 }, - ], - profile: "trekking", - }), - }); - test.skip(!resp.ok, "BRouter not available — start with pnpm dev:full"); - } catch { - test.skip(true, "BRouter not available"); - } - }); - test("computes route between Berlin waypoints", async ({ request }) => { const response = await request.post(`${PLANNER}/api/route`, { data: { @@ -108,7 +67,6 @@ test.describe("Integration: BRouter routing", () => { const geojson = await response.json(); const coords = geojson.features[0].geometry.coordinates; - // Route should pass near the middle waypoint (52.516, 13.377) const nearMiddle = coords.some( (c: number[]) => Math.abs(c[1] - 52.516) < 0.005 && Math.abs(c[0] - 13.377) < 0.005, diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index d3d6751..0d7d67c 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -6,18 +6,6 @@ test.describe("Planner", () => { await expect(page).toHaveTitle("trails.cool Planner"); await expect(page.getByText("Collaborative route planning")).toBeVisible(); }); -}); - -// Tests that require PostgreSQL — skip in CI -test.describe("Planner (requires DB)", () => { - test.beforeEach(async ({ request }) => { - try { - const resp = await request.post("/api/sessions", { data: {} }); - if (!resp.ok()) test.skip(); - } catch { - test.skip(); - } - }); test("can create a session via API", async ({ request }) => { const response = await request.post("/api/sessions", { data: {} }); From 83009c2b9bf524d921124e91a4c5e8d52658d50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:03:15 +0100 Subject: [PATCH 2/7] Fix withDb: re-throw React Router ErrorResponseImpl React Router's data() throws ErrorResponseImpl, not Response. Check for objects with status + data properties to catch both Response and ErrorResponseImpl, preventing withDb from swallowing 404s as 503s. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 46f0108..df18f2e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,7 +21,13 @@ export type Database = ReturnType; export function withDb(handler: () => Promise): Promise { return handler().catch((error) => { // Re-throw React Router responses (redirects, data() throws) - if (error instanceof Response) throw error; + // Check for Response, ErrorResponseImpl, or anything with a status property + if ( + error instanceof Response || + (error && typeof error === "object" && "status" in error && "data" in error) + ) { + throw error; + } // Any other error from a DB-wrapped handler is treated as DB unavailable const message = error instanceof Error ? error.message : String(error); From dee6f2806ff78783def3a546ebc6c27385d9ab51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:06:10 +0100 Subject: [PATCH 3/7] Add root ErrorBoundary to both apps, simplify withDb Both apps now have proper ErrorBoundary exports in root.tsx: - 404: "Page not found" - 503: "Service temporarily unavailable" - Other: shows error message Simplified withDb re-throw check: anything with a "status" property is treated as a React Router response (covers Response and ErrorResponseImpl). DB errors throw a plain Response(503) that the error boundary renders nicely. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/root.tsx | 33 ++++++++++++++++++++++++++++++++- apps/planner/app/root.tsx | 31 ++++++++++++++++++++++++++++++- packages/db/src/index.ts | 15 +++++++-------- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index ca53230..f68979f 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,5 +1,6 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from "react-router"; import type { LinksFunction } from "react-router"; +import type { Route } from "./+types/root"; import stylesheet from "@trails-cool/ui/styles.css?url"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; @@ -25,3 +26,33 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ; } + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + return ( +
+

{error.status}

+

+ {error.status === 404 && "Page not found"} + {error.status === 503 && "Service temporarily unavailable. Please try again later."} + {error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")} +

+ + Go home + +
+ ); + } + + return ( +
+

Error

+

+ {error instanceof Error ? error.message : "An unexpected error occurred"} +

+ + Go home + +
+ ); +} diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index 17b1eb6..bf39ded 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -1,5 +1,6 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from "react-router"; import type { LinksFunction } from "react-router"; +import type { Route } from "./+types/root"; import stylesheet from "@trails-cool/ui/styles.css?url"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; @@ -25,3 +26,31 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ; } + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + return ( +
+
+

{error.status}

+

+ {error.status === 404 && "Page not found"} + {error.status === 503 && "Service temporarily unavailable"} + {error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")} +

+
+
+ ); + } + + return ( +
+
+

Error

+

+ {error instanceof Error ? error.message : "An unexpected error occurred"} +

+
+
+ ); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index df18f2e..26f84fa 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -20,23 +20,22 @@ export type Database = ReturnType; */ export function withDb(handler: () => Promise): Promise { return handler().catch((error) => { - // Re-throw React Router responses (redirects, data() throws) - // Check for Response, ErrorResponseImpl, or anything with a status property + // Re-throw anything that looks like a React Router response: + // - Response (redirects) + // - ErrorResponseImpl from data() throws (has status + data) if ( error instanceof Response || - (error && typeof error === "object" && "status" in error && "data" in error) + (error != null && typeof error === "object" && "status" in error) ) { throw error; } - // Any other error from a DB-wrapped handler is treated as DB unavailable + // Database error — throw as a 503 that the error boundary will catch const message = error instanceof Error ? error.message : String(error); console.error("[withDb] Database error:", message); - throw new Response(JSON.stringify({ error: "Database unavailable" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); + // Use the same shape as data() throw so isRouteErrorResponse works + throw new Response("Database unavailable", { status: 503, statusText: "Service Unavailable" }); }); } From f0afa8a4a7f7309a977a3570cc308741905848f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:11:48 +0100 Subject: [PATCH 4/7] Fix withDb: detect DataWithResponseInit from data() throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Router's data() throw creates a DataWithResponseInit object (type + data + init), not a Response or ErrorResponseImpl. Check for type === "DataWithResponseInit" to re-throw correctly. Verified: nonexistent session → 404, DB down → 503. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 26f84fa..5d5e6b8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -20,12 +20,12 @@ export type Database = ReturnType; */ export function withDb(handler: () => Promise): Promise { return handler().catch((error) => { - // Re-throw anything that looks like a React Router response: - // - Response (redirects) - // - ErrorResponseImpl from data() throws (has status + data) + // Re-throw React Router responses and data() throws: + // - Response instances (redirects, manual responses) + // - DataWithResponseInit from data() throws (type + data + init) if ( error instanceof Response || - (error != null && typeof error === "object" && "status" in error) + (error != null && typeof error === "object" && error.type === "DataWithResponseInit") ) { throw error; } @@ -34,7 +34,6 @@ export function withDb(handler: () => Promise): Promise { const message = error instanceof Error ? error.message : String(error); console.error("[withDb] Database error:", message); - // Use the same shape as data() throw so isRouteErrorResponse works throw new Response("Database unavailable", { status: 503, statusText: "Service Unavailable" }); }); } From f2f5c6a83ee721407b172f87e5a13a92142cf45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:15:26 +0100 Subject: [PATCH 5/7] Cache Playwright browsers in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright browser download (25s) is now cached by pnpm-lock hash. On cache hit, only install system deps (fast). On miss, full install. Current E2E timings (first run): - Postgres container: 19s - BRouter JAR: cached - Berlin segment: 6s (will be cached next run) - Playwright install: 25s → cached after first run - BRouter startup: 9s - Tests: 27s Expected after caching: ~60s total (down from ~107s). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c07ab8..6e4b288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,20 @@ jobs: env: BROUTER_URL: http://localhost:17777 - - run: pnpm exec playwright install --with-deps chromium + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright deps only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium - name: Run E2E tests run: pnpm test:e2e From 1d45c57ae74da376f6b981c70fad38fb633d6f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:17:30 +0100 Subject: [PATCH 6/7] Cache PostGIS Docker image in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace service container with manual docker run + cached image tar. Service containers pull the image before steps run (uncacheable). Manual approach: cache the image as a tar, load on cache hit. PostGIS image is ~400MB — first run pulls and saves, subsequent runs load from cache in ~2s instead of ~19s. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e4b288..e3df822 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,20 +67,6 @@ jobs: name: E2E Tests needs: build runs-on: ubuntu-latest - services: - postgres: - image: postgis/postgis:16-3.4 - env: - POSTGRES_USER: trails - POSTGRES_PASSWORD: trails - POSTGRES_DB: trails - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U trails" - --health-interval 5s - --health-timeout 5s - --health-retries 5 env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: @@ -92,6 +78,36 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile + - name: Cache PostGIS Docker image + id: postgis-cache + uses: actions/cache@v4 + with: + path: /tmp/postgis-image.tar + key: postgis-16-3.4 + + - name: Load or pull PostGIS image + run: | + if [ -f /tmp/postgis-image.tar ]; then + docker load < /tmp/postgis-image.tar + else + docker pull postgis/postgis:16-3.4 + docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar + fi + + - name: Start PostgreSQL + run: | + docker run -d --name postgres \ + -e POSTGRES_USER=trails \ + -e POSTGRES_PASSWORD=trails \ + -e POSTGRES_DB=trails \ + -p 5432:5432 \ + postgis/postgis:16-3.4 + # Wait for health + for i in $(seq 1 30); do + docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break + sleep 1 + done + - name: Push database schema run: pnpm db:push From 70d9d71a5c4726202d284bfdecbef7da3b72b954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:21:25 +0100 Subject: [PATCH 7/7] Fix CI: wait for PostGIS extension before schema push pg_isready reports true before PostGIS extension is initialized. Added a second wait loop that queries PostGIS_Version() to ensure the extension is fully loaded before running drizzle-kit push. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3df822..e4708a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,11 +102,16 @@ jobs: -e POSTGRES_DB=trails \ -p 5432:5432 \ postgis/postgis:16-3.4 - # Wait for health + # Wait for pg_isready for i in $(seq 1 30); do docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break sleep 1 done + # Wait for PostGIS extension to be ready + for i in $(seq 1 10); do + docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break + sleep 1 + done - name: Push database schema run: pnpm db:push