diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 253a052..e4708a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,8 @@ jobs: name: E2E Tests needs: build runs-on: ubuntu-latest + env: + DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 @@ -75,8 +77,109 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium - - run: pnpm test:e2e + + - 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 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 + + - 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 + + - 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 + env: + BROUTER_URL: http://localhost:17777 + - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: 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/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: {} }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 46f0108..5d5e6b8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -20,17 +20,21 @@ 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; + // 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" && error.type === "DataWithResponseInit") + ) { + 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" }, - }); + throw new Response("Database unavailable", { status: 503, statusText: "Service Unavailable" }); }); }