diff --git a/e2e/helpers/planner.ts b/e2e/helpers/planner.ts new file mode 100644 index 0000000..1b81289 --- /dev/null +++ b/e2e/helpers/planner.ts @@ -0,0 +1,12 @@ +import type { APIRequestContext, Page } from "@playwright/test"; + +export async function createSession(request: APIRequestContext): Promise { + const response = await request.post("/api/sessions", { data: {} }); + const { url } = await response.json(); + return url; +} + +export async function openSession(page: Page, url: string): Promise { + await page.goto(url); + await page.getByText("Connected").waitFor({ timeout: 15000 }); +} diff --git a/e2e/planner-coloring.test.ts b/e2e/planner-coloring.test.ts new file mode 100644 index 0000000..1906fd0 --- /dev/null +++ b/e2e/planner-coloring.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from "./fixtures/test"; +import { mockBRouter } from "./fixtures/brouter-mock"; +import { createSession } from "./helpers/planner"; + +test.beforeEach(async ({ page }) => { + await mockBRouter(page); +}); + +const WITH_ROUTE = encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, +])); + +test.describe("Planner – route coloring", () => { + test("session has color mode toggle", async ({ page, request }) => { + const url = await createSession(request); + + await page.goto(`${url}?waypoints=${WITH_ROUTE}`); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); + await expect(select).toHaveValue("plain"); + + await select.selectOption("elevation"); + await expect(select).toHaveValue("elevation"); + }); + + test("road type color mode renders chart and shows legend", async ({ page, request }) => { + const url = await createSession(request); + + await page.goto(`${url}?waypoints=${WITH_ROUTE}`); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); + + await select.selectOption("highway"); + await expect(select).toHaveValue("highway"); + + await expect(page.getByText("Road Type Profile")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("secondary")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("residential")).toBeVisible({ timeout: 5000 }); + }); + + test("road type hover label shows highway type", async ({ page, request }) => { + const url = await createSession(request); + + await page.goto(`${url}?waypoints=${WITH_ROUTE}`); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); + await select.selectOption("highway"); + + const canvas = page.locator("canvas"); + await expect(canvas).toBeVisible({ timeout: 5000 }); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + } + + await expect(canvas).toBeVisible(); + }); +}); diff --git a/e2e/planner-multiday.test.ts b/e2e/planner-multiday.test.ts new file mode 100644 index 0000000..c664e3f --- /dev/null +++ b/e2e/planner-multiday.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from "./fixtures/test"; +import { mockBRouter } from "./fixtures/brouter-mock"; +import { createSession, openSession } from "./helpers/planner"; + +test.beforeEach(async ({ page }) => { + await mockBRouter(page); +}); + +test.describe("Planner – multi-day routes", () => { + test("toggle overnight on waypoint shows day breakdown in sidebar", async ({ page, request }) => { + const url = await createSession(request); + + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.516, lon: 13.377 }, + { lat: 52.510, lon: 13.390 }, + ]))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 15000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 15000 }); + + const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") }); + const secondRow = waypointRows.nth(1); + await secondRow.hover(); + const moonButton = secondRow.getByTitle(/overnight/i); + await moonButton.click(); + + await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); + await expect(sidebar.getByText("Day 2")).toBeVisible({ timeout: 5000 }); + }); + + test("export GPX with day breaks includes overnight metadata", async ({ page, request }) => { + const url = await createSession(request); + + const gpx = ` + + Berlin + Mitteovernight + Kreuzberg + + 34 + 40 + 35 + +`; + + await openSession(page, url); + + const dataTransfer = await page.evaluateHandle((content) => { + const dt = new DataTransfer(); + const file = new File([content], "multi-day.gpx", { type: "application/gpx+xml" }); + dt.items.add(file); + return dt; + }, gpx); + + const map = page.locator(".leaflet-container"); + await map.dispatchEvent("dragenter", { dataTransfer }); + await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); + page.on("dialog", (dialog) => dialog.accept()); + await map.dispatchEvent("drop", { dataTransfer }); + + await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/planner-overlays.test.ts b/e2e/planner-overlays.test.ts new file mode 100644 index 0000000..d863a5f --- /dev/null +++ b/e2e/planner-overlays.test.ts @@ -0,0 +1,72 @@ +import { test, expect } from "./fixtures/test"; +import { createSession } from "./helpers/planner"; + +test.describe("Planner – overlays", () => { + test("enable hillshading overlay loads tiles", async ({ page, request }) => { + const url = await createSession(request); + + const hillshadingRequests: string[] = []; + await page.route("**/tiles.wmflabs.org/hillshading/**", async (route) => { + hillshadingRequests.push(route.request().url()); + await route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRUEFTuQmCC", "base64"), + }); + }); + + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + + await page.evaluate(() => { + const inputs = document.querySelectorAll(".leaflet-control-layers-overlays input"); + if (inputs[0]) inputs[0].click(); + }); + + await page.waitForTimeout(2000); + expect(hillshadingRequests.length).toBeGreaterThan(0); + }); + + test("enable POI category shows markers on map", async ({ page, request }) => { + const url = await createSession(request); + + await page.route("**/api/overpass", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + elements: [ + { + type: "node", + id: 12345, + lat: 52.52, + lon: 13.405, + tags: { amenity: "drinking_water", name: "Test Brunnen" }, + }, + ], + }), + }); + }); + + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + await page.evaluate(() => { + const map = (window as any).__leafletMap; + if (map) map.setView([52.52, 13.405], 14, { animate: false }); + }); + await page.waitForTimeout(500); + + await page.getByTitle("Points of Interest").click(); + await page.getByText("Drinking water").click(); + + await page.waitForTimeout(3000); + + const markerCount = await page.locator(".leaflet-marker-pane .leaflet-marker-icon").count(); + expect(markerCount).toBeGreaterThan(0); + + const panelText = await page.getByText("Drinking water").textContent(); + expect(panelText).toBeTruthy(); + }); +}); diff --git a/e2e/planner-routing.test.ts b/e2e/planner-routing.test.ts new file mode 100644 index 0000000..2b0077e --- /dev/null +++ b/e2e/planner-routing.test.ts @@ -0,0 +1,153 @@ +import { test, expect } from "./fixtures/test"; +import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock"; +import { createSession, openSession } from "./helpers/planner"; + +test.beforeEach(async ({ page }) => { + await mockBRouter(page); +}); + +test.describe("Planner – routing", () => { + test("clicking on route splits it by inserting a waypoint", async ({ page, request }) => { + const url = await createSession(request); + + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, + ]))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); + + await page.evaluate(() => { + const map = (window as any).__leafletMap; + if (!map) return; + map.setView([52.518, 13.383], 15, { animate: false }); + }); + await page.waitForTimeout(500); + + const pixel = await latLngToPixel(page, 52.518, 13.383); + await page.mouse.click(pixel.x, pixel.y); + + await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); + }); + + test("map zooms to fit the route when opened with initial waypoints", async ({ page, request }) => { + const url = await createSession(request); + + const waypoints = [ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, + ]; + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify(waypoints))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); + + const zoom = await page.evaluate(() => { + const map = (window as any).__leafletMap; + return map ? map.getZoom() : null; + }); + expect(zoom).not.toBeNull(); + expect(zoom).toBeGreaterThan(6); + }); + + test("waypoint note roundtrips through GPX import and export", async ({ page, request }) => { + const url = await createSession(request); + + const gpxWithNote = ` + + BerlinRefill water here + Spandau + + 34 + 40 + +`; + + await openSession(page, url); + + const dataTransfer = await page.evaluateHandle((content) => { + const dt = new DataTransfer(); + const file = new File([content], "noted.gpx", { type: "application/gpx+xml" }); + dt.items.add(file); + return dt; + }, gpxWithNote); + + const map = page.locator(".leaflet-container"); + await map.dispatchEvent("dragenter", { dataTransfer }); + await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); + page.on("dialog", (dialog) => dialog.accept()); + await map.dispatchEvent("drop", { dataTransfer }); + + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 10000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText("Refill water here")).toBeVisible({ timeout: 5000 }); + + await page.getByRole("button", { name: "▾" }).click(); + await expect(page.getByText("Export Plan")).toBeVisible({ timeout: 3000 }); + const downloadPromise = page.waitForEvent("download"); + await page.getByText("Export Plan").first().click(); + const download = await downloadPromise; + const gpxStream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of gpxStream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const gpxText = Buffer.concat(chunks).toString("utf-8"); + + expect(gpxText).toContain("Refill water here"); + expect(gpxText.indexOf("Refill water here")); + }); + + test("nearby POI snap moves waypoint and prepends note prefix", async ({ page, request }) => { + const url = await createSession(request); + + await page.route("**/api/overpass", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + elements: [ + { + type: "node", + id: 99001, + lat: 52.521, + lon: 13.406, + tags: { amenity: "drinking_water", name: "Stadtbrunnen" }, + }, + ], + }), + }); + }); + + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405, name: "Start" }, + { lat: 52.515, lon: 13.351, name: "End" }, + ]))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); + + const sidebar = page.locator("aside"); + + const firstRow = sidebar.locator("li").filter({ has: page.locator("span.rounded-full").first() }).first(); + await firstRow.click(); + + await expect(sidebar.getByText("Nearby")).toBeVisible({ timeout: 5000 }); + await expect(sidebar.getByText("Stadtbrunnen")).toBeVisible({ timeout: 5000 }); + + const snapButton = sidebar.locator("li").filter({ hasText: "Stadtbrunnen" }).getByRole("button", { name: /snap/i }); + await snapButton.click(); + + await expect(firstRow.locator("p.italic")).not.toHaveText(/Add a note/); + const noteText = await firstRow.locator("p.italic").textContent(); + expect(noteText).toContain("Stadtbrunnen"); + }); +}); diff --git a/e2e/planner-session.test.ts b/e2e/planner-session.test.ts new file mode 100644 index 0000000..7b53d75 --- /dev/null +++ b/e2e/planner-session.test.ts @@ -0,0 +1,132 @@ +import { test, expect } from "./fixtures/test"; +import { createSession, openSession } from "./helpers/planner"; + +test.describe("Planner – session", () => { + test("loads the home page", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/trails\.cool Planner/); + await expect(page.getByRole("link", { name: "Start Planning" })).toBeVisible(); + }); + + test("can create a session via API", async ({ request }) => { + const response = await request.post("/api/sessions", { data: {} }); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.sessionId).toBeTruthy(); + expect(body.url).toContain("/session/"); + }); + + test("session page loads with map", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.getByRole("link", { name: "trails.cool Planner" })).toBeVisible(); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + }); + + test("session shows connection status", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + }); + + test("session has profile selector", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + const profileSelect = page.getByLabel("Profile:"); + await expect(profileSelect).toBeVisible(); + await expect(profileSelect).toHaveValue("fastbike"); + }); + + test("session has export GPX button", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.getByRole("button", { name: "Export GPX" })).toBeVisible({ timeout: 10000 }); + }); + + test("session shows empty waypoints sidebar", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.getByText("Waypoints (0)")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Click on the map to add waypoints")).toBeVisible(); + }); + + test("session has sidebar tabs (waypoints and notes)", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + + await expect(page.getByRole("button", { name: "Waypoints" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Notes" })).toBeVisible(); + + await page.getByRole("button", { name: "Notes" }).click(); + await expect(page.getByText("Add notes for this session...")).toBeVisible(); + + await page.getByRole("button", { name: "Waypoints" }).click(); + await expect(page.getByText("Waypoints (0)")).toBeVisible(); + }); + + test("session has no-go area button", async ({ page, request }) => { + const url = await createSession(request); + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTitle("Draw no-go area")).toBeVisible(); + }); + + test("can create session with initial waypoints via API", async ({ request }) => { + const response = await request.post("/api/sessions", { + data: { + gpx: 'BerlinMunich', + }, + }); + const body = await response.json(); + expect(body.initialWaypoints).toHaveLength(2); + expect(body.initialWaypoints[0].name).toBe("Berlin"); + }); + + test("expired session returns 404", async ({ page }) => { + const response = await page.goto("/session/nonexistent-id"); + expect(response?.status()).toBe(404); + }); + + test("home page has Import GPX button", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Import GPX")).toBeVisible(); + }); + + test("import invalid GPX shows error", async ({ page }) => { + await page.goto("/"); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Import GPX").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "broken.gpx", + mimeType: "application/gpx+xml", + buffer: Buffer.from("not valid xml at all"), + }); + await expect(page.getByText(/Could not read|konnte nicht/)).toBeVisible({ timeout: 5000 }); + await expect(page).toHaveURL(/^\/$|\/$/); + }); + + test("import GPX from home page creates session with waypoints", async ({ page }) => { + await page.goto("/"); + const gpx = ` + + + 34 + 113 + 519 + +`; + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Import GPX").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "test-route.gpx", + mimeType: "application/gpx+xml", + buffer: Buffer.from(gpx), + }); + await expect(page).toHaveURL(/\/session\//, { timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts deleted file mode 100644 index b60b826..0000000 --- a/e2e/planner.test.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { test, expect } from "./fixtures/test"; -import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock"; - -test.describe("Planner", () => { - test("loads the home page", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveTitle(/trails\.cool Planner/); - await expect(page.getByRole("link", { name: "Start Planning" })).toBeVisible(); - }); - - test("can create a session via API", async ({ request }) => { - const response = await request.post("/api/sessions", { data: {} }); - expect(response.ok()).toBeTruthy(); - const body = await response.json(); - expect(body.sessionId).toBeTruthy(); - expect(body.url).toContain("/session/"); - }); - - test("session page loads with map", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - // Title is now a home link - await expect(page.getByRole("link", { name: "trails.cool Planner" })).toBeVisible(); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - }); - - test("session shows connection status", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - }); - - test("session has profile selector", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - - const profileSelect = page.getByLabel("Profile:"); - await expect(profileSelect).toBeVisible(); - await expect(profileSelect).toHaveValue("fastbike"); - }); - - test("session has export GPX button", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.getByRole("button", { name: "Export GPX" })).toBeVisible({ timeout: 10000 }); - }); - - test("session shows empty waypoints sidebar", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.getByText("Waypoints (0)")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Click on the map to add waypoints")).toBeVisible(); - }); - - test("session has sidebar tabs (waypoints and notes)", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - - // Waypoints tab is active by default - await expect(page.getByRole("button", { name: "Waypoints" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Notes" })).toBeVisible(); - - // Switch to Notes tab - await page.getByRole("button", { name: "Notes" }).click(); - // CodeMirror editor should be visible with the placeholder text - await expect(page.getByText("Add notes for this session...")).toBeVisible(); - - // Switch back to Waypoints tab - await page.getByRole("button", { name: "Waypoints" }).click(); - await expect(page.getByText("Waypoints (0)")).toBeVisible(); - }); - - test("clicking on route splits it by inserting a waypoint", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405 }, - { lat: 52.515, lon: 13.351 }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); - - const sidebar = page.locator("aside"); - await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); - - // Zoom in to the route midpoint using known coordinates - await page.evaluate(() => { - const map = (window as any).__leafletMap; - if (!map) return; - map.setView([52.518, 13.383], 15, { animate: false }); - }); - await page.waitForTimeout(500); - - // Click on a known route coordinate to insert a waypoint - const pixel = await latLngToPixel(page, 52.518, 13.383); - await page.mouse.click(pixel.x, pixel.y); - - // Should now have 3 waypoints (original 2 + inserted) - await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); - }); - - // Note: Ghost marker hover interaction is verified visually via cmux browser. - // Playwright's mouse simulation doesn't reliably trigger Leaflet's SVG - // pointer events needed for the distance-based snap detection. - - test("session has color mode toggle", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - // Need a route for the elevation chart (and color selector) to appear - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405 }, - { lat: 52.515, lon: 13.351 }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - // Wait for elevation chart to render with the color mode selector - await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); - const select = page.locator("select", { has: page.locator("option[value='highway']") }); - await expect(select).toBeVisible({ timeout: 5000 }); - await expect(select).toHaveValue("plain"); - - // Switch to elevation - await select.selectOption("elevation"); - await expect(select).toHaveValue("elevation"); - }); - - test("session has no-go area button", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByTitle("Draw no-go area")).toBeVisible(); - }); - - test("map zooms to fit the route when opened with initial waypoints", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - const waypoints = [ - { lat: 52.520, lon: 13.405 }, - { lat: 52.515, lon: 13.351 }, - ]; - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify(waypoints))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - const sidebar = page.locator("aside"); - await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); - - // The map should have zoomed in to the route bounds (zoom > default 6) - const zoom = await page.evaluate(() => { - const map = (window as any).__leafletMap; - return map ? map.getZoom() : null; - }); - expect(zoom).not.toBeNull(); - expect(zoom).toBeGreaterThan(6); - }); - - test("can create session with initial waypoints", async ({ request }) => { - const response = await request.post("/api/sessions", { - data: { - gpx: 'BerlinMunich', - }, - }); - const body = await response.json(); - expect(body.initialWaypoints).toHaveLength(2); - expect(body.initialWaypoints[0].name).toBe("Berlin"); - }); - - test("expired session returns 404", async ({ page }) => { - const response = await page.goto("/session/nonexistent-id"); - expect(response?.status()).toBe(404); - }); - - test("home page has Import GPX button", async ({ page }) => { - await page.goto("/"); - await expect(page.getByText("Import GPX")).toBeVisible(); - }); - - test("import invalid GPX shows error", async ({ page }) => { - await page.goto("/"); - const fileChooserPromise = page.waitForEvent("filechooser"); - await page.getByText("Import GPX").click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles({ - name: "broken.gpx", - mimeType: "application/gpx+xml", - buffer: Buffer.from("not valid xml at all"), - }); - - // Should show error, stay on home page - await expect(page.getByText(/Could not read|konnte nicht/)).toBeVisible({ timeout: 5000 }); - await expect(page).toHaveURL(/^\/$|\/$/); - }); - - test("import GPX from home page creates session with waypoints", async ({ page }) => { - await page.goto("/"); - const gpx = ` - - - 34 - 113 - 519 - -`; - const fileChooserPromise = page.waitForEvent("filechooser"); - await page.getByText("Import GPX").click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles({ - name: "test-route.gpx", - mimeType: "application/gpx+xml", - buffer: Buffer.from(gpx), - }); - - // Should redirect to a session - await expect(page).toHaveURL(/\/session\//, { timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - }); - - test("toggle overnight on waypoint shows day breakdown in sidebar", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405 }, - { lat: 52.516, lon: 13.377 }, - { lat: 52.510, lon: 13.390 }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 15000 }); - - const sidebar = page.locator("aside"); - await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 15000 }); - - // Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon) - const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") }); - const secondRow = waypointRows.nth(1); - await secondRow.hover(); - const moonButton = secondRow.getByTitle(/overnight/i); - await moonButton.click(); - - // Day breakdown should appear in the sidebar - await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); - await expect(sidebar.getByText("Day 2")).toBeVisible({ timeout: 5000 }); - }); - - test("export GPX with day breaks includes overnight metadata", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - // Import a GPX with overnight waypoints - const gpx = ` - - Berlin - Mitteovernight - Kreuzberg - - 34 - 40 - 35 - -`; - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - // Drop the GPX file onto the map - const dataTransfer = await page.evaluateHandle((content) => { - const dt = new DataTransfer(); - const file = new File([content], "multi-day.gpx", { type: "application/gpx+xml" }); - dt.items.add(file); - return dt; - }, gpx); - - const map = page.locator(".leaflet-container"); - - await map.dispatchEvent("dragenter", { dataTransfer }); - await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); - - page.on("dialog", (dialog) => dialog.accept()); - await map.dispatchEvent("drop", { dataTransfer }); - - // Wait for waypoints to load - await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); - - // The overnight waypoint should show day breakdown in the sidebar - const sidebar = page.locator("aside"); - await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); - }); - - test("enable hillshading overlay loads tiles", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - // Track hillshading tile requests - const hillshadingRequests: string[] = []; - await page.route("**/tiles.wmflabs.org/hillshading/**", async (route) => { - hillshadingRequests.push(route.request().url()); - await route.fulfill({ - status: 200, - contentType: "image/png", - body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRUEFTuQmCC", "base64"), - }); - }); - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - - // Enable hillshading via DOM — Leaflet's layers control hover is hard to automate - await page.evaluate(() => { - const inputs = document.querySelectorAll(".leaflet-control-layers-overlays input"); - if (inputs[0]) inputs[0].click(); - }); - - await page.waitForTimeout(2000); - expect(hillshadingRequests.length).toBeGreaterThan(0); - }); - - test("road type color mode renders chart and shows legend", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405 }, - { lat: 52.515, lon: 13.351 }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - // Wait for the elevation chart to render, then find the color mode select (has a "highway" option) - await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); - const select = page.locator("select", { has: page.locator("option[value='highway']") }); - await expect(select).toBeVisible({ timeout: 5000 }); - - // Switch to highway (road type) mode - await select.selectOption("highway"); - await expect(select).toHaveValue("highway"); - - // Chart title should show "Road Type Profile" - await expect(page.getByText("Road Type Profile")).toBeVisible({ timeout: 5000 }); - - // Legend should show highway types from mock data - await expect(page.getByText("secondary")).toBeVisible({ timeout: 5000 }); - await expect(page.getByText("residential")).toBeVisible({ timeout: 5000 }); - }); - - test("road type hover label shows highway type", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405 }, - { lat: 52.515, lon: 13.351 }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - // Switch to highway mode - await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); - const select = page.locator("select", { has: page.locator("option[value='highway']") }); - await expect(select).toBeVisible({ timeout: 5000 }); - await select.selectOption("highway"); - - // Hover the canvas to trigger the hover label - const canvas = page.locator("canvas"); - await expect(canvas).toBeVisible({ timeout: 5000 }); - const box = await canvas.boundingBox(); - if (box) { - // Hover near the middle of the chart - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - } - - // The hover label is drawn on canvas — we can't assert text directly, - // but we verify no errors occurred and the chart re-renders on hover. - // The canvas should still be visible (no crash). - await expect(canvas).toBeVisible(); - }); - - test("enable POI category shows markers on map", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - // The browser queries POIs through the planner's `/api/overpass` - // proxy (not the upstream Overpass directly), so that's what we - // intercept here. Response body is the same Overpass JSON shape. - await page.route("**/api/overpass", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - elements: [ - { - type: "node", - id: 12345, - lat: 52.52, - lon: 13.405, - tags: { amenity: "drinking_water", name: "Test Brunnen" }, - }, - ], - }), - }); - }); - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - // Zoom in to Berlin center to meet zoom threshold and see the mock POI - await page.evaluate(() => { - const map = (window as any).__leafletMap; - if (map) map.setView([52.52, 13.405], 14, { animate: false }); - }); - await page.waitForTimeout(500); - - // Open POI panel and enable Drinking water - await page.getByTitle("Points of Interest").click(); - await page.getByText("Drinking water").click(); - - // Wait for mock Overpass response and marker rendering - await page.waitForTimeout(3000); - - // Verify POI marker rendered — check for marker with the water emoji in the pane - const markerCount = await page.locator(".leaflet-marker-pane .leaflet-marker-icon").count(); - // Should have at least one POI marker (beyond any waypoint markers) - expect(markerCount).toBeGreaterThan(0); - - // Verify the Overpass mock was actually called by checking the POI panel count - const panelText = await page.getByText("Drinking water").textContent(); - expect(panelText).toBeTruthy(); - }); - - test("waypoint note roundtrips through GPX import and export", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - // Drop a GPX with a on a onto the map - const gpxWithNote = ` - - BerlinRefill water here - Spandau - - 34 - 40 - -`; - - await page.goto(url); - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - - const dataTransfer = await page.evaluateHandle((content) => { - const dt = new DataTransfer(); - const file = new File([content], "noted.gpx", { type: "application/gpx+xml" }); - dt.items.add(file); - return dt; - }, gpxWithNote); - - const map = page.locator(".leaflet-container"); - await map.dispatchEvent("dragenter", { dataTransfer }); - await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); - page.on("dialog", (dialog) => dialog.accept()); - await map.dispatchEvent("drop", { dataTransfer }); - - await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 10000 }); - - // The note should be visible in the sidebar - const sidebar = page.locator("aside"); - await expect(sidebar.getByText("Refill water here")).toBeVisible({ timeout: 5000 }); - - // Export as plan and verify the note is preserved - await page.getByRole("button", { name: "▾" }).click(); - await expect(page.getByText("Export Plan")).toBeVisible({ timeout: 3000 }); - const downloadPromise = page.waitForEvent("download"); - await page.getByText("Export Plan").first().click(); - const download = await downloadPromise; - const gpxStream = await download.createReadStream(); - const chunks: Buffer[] = []; - for await (const chunk of gpxStream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - const gpxText = Buffer.concat(chunks).toString("utf-8"); - - expect(gpxText).toContain("Refill water here"); - expect(gpxText.indexOf("Refill water here")); - }); - - test("nearby POI snap moves waypoint and prepends note prefix", async ({ page, request }) => { - const sessionResp = await request.post("/api/sessions", { data: {} }); - const { url } = await sessionResp.json(); - - await mockBRouter(page); - - // Mock the Overpass proxy to return a nearby POI - await page.route("**/api/overpass", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - elements: [ - { - type: "node", - id: 99001, - lat: 52.521, - lon: 13.406, - tags: { amenity: "drinking_water", name: "Stadtbrunnen" }, - }, - ], - }), - }); - }); - - await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ - { lat: 52.520, lon: 13.405, name: "Start" }, - { lat: 52.515, lon: 13.351, name: "End" }, - ]))}`); - - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); - await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); - - const sidebar = page.locator("aside"); - - // Click the first waypoint row to select it (triggers nearby POI fetch) - const firstRow = sidebar.locator("li").filter({ has: page.locator("span.rounded-full").first() }).first(); - await firstRow.click(); - - // Wait for the Nearby section to appear with the mock POI - await expect(sidebar.getByText("Nearby")).toBeVisible({ timeout: 5000 }); - await expect(sidebar.getByText("Stadtbrunnen")).toBeVisible({ timeout: 5000 }); - - // Click the snap button for that POI - const snapButton = sidebar.locator("li").filter({ hasText: "Stadtbrunnen" }).getByRole("button", { name: /snap/i }); - await snapButton.click(); - - // The waypoint note should now contain the POI prefix (water emoji + name) - await expect(firstRow.locator("p.italic")).not.toHaveText(/Add a note/); - const noteText = await firstRow.locator("p.italic").textContent(); - expect(noteText).toContain("Stadtbrunnen"); - }); -}); diff --git a/openspec/changes/e2e-test-reorganization/.openspec.yaml b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/.openspec.yaml similarity index 100% rename from openspec/changes/e2e-test-reorganization/.openspec.yaml rename to openspec/changes/archive/2026-05-22-e2e-test-reorganization/.openspec.yaml diff --git a/openspec/changes/archive/2026-05-22-e2e-test-reorganization/design.md b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/design.md new file mode 100644 index 0000000..548258f --- /dev/null +++ b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/design.md @@ -0,0 +1,41 @@ +## Approach + +Split `e2e/planner.test.ts` (581 lines, 42+ tests) into 5 feature-focused files. Extract a shared helper for the common create-session + navigate + wait-connected pattern. Make BRouter mocked by default in files where routing is needed via `test.beforeEach`. + +## File Map + +### New files + +| File | Tests | BRouter | +|------|-------|---------| +| `e2e/planner-session.test.ts` | Home page, session creation, basic session UI, GPX import | No mock needed | +| `e2e/planner-routing.test.ts` | Route splitting, zoom to fit, waypoint notes, POI snap | `beforeEach` mock | +| `e2e/planner-multiday.test.ts` | Overnight toggle, multi-day GPX export | `beforeEach` mock | +| `e2e/planner-overlays.test.ts` | Hillshading tiles, POI category markers | No mock needed | +| `e2e/planner-coloring.test.ts` | Color mode, road type chart, hover label | `beforeEach` mock | + +### New helper + +`e2e/helpers/planner.ts` — exports: +- `createSession(request)` → `string` (session URL) +- `openSession(page, url)` → `Promise` (goto + waitForConnected) + +### Unchanged + +`e2e/fixtures/brouter-mock.ts` — stays as-is; each file that needs it calls `mockBRouter(page)` in `beforeEach`. + +### Deleted + +`e2e/planner.test.ts` + +## BRouter Default Mock + +Files whose tests always need a computed route add: + +```ts +test.beforeEach(async ({ page }) => { + await mockBRouter(page); +}); +``` + +This is explicit and discoverable. Tests in `planner-session.test.ts` and `planner-overlays.test.ts` never need routing, so they don't mock BRouter at all. diff --git a/openspec/changes/e2e-test-reorganization/proposal.md b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/proposal.md similarity index 100% rename from openspec/changes/e2e-test-reorganization/proposal.md rename to openspec/changes/archive/2026-05-22-e2e-test-reorganization/proposal.md diff --git a/openspec/changes/e2e-test-reorganization/specs/e2e-testing/spec.md b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/specs/e2e-testing/spec.md similarity index 100% rename from openspec/changes/e2e-test-reorganization/specs/e2e-testing/spec.md rename to openspec/changes/archive/2026-05-22-e2e-test-reorganization/specs/e2e-testing/spec.md diff --git a/openspec/changes/archive/2026-05-22-e2e-test-reorganization/tasks.md b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/tasks.md new file mode 100644 index 0000000..c080311 --- /dev/null +++ b/openspec/changes/archive/2026-05-22-e2e-test-reorganization/tasks.md @@ -0,0 +1,15 @@ +## 1. Shared helper + +- [x] 1.1 Create `e2e/helpers/planner.ts` with `createSession(request)` and `openSession(page, url)` helpers + +## 2. Split test files + +- [x] 2.1 Create `e2e/planner-session.test.ts` — home page, session creation, basic session UI, GPX import (14 tests) +- [x] 2.2 Create `e2e/planner-routing.test.ts` — route splitting, zoom to fit, waypoint notes, POI snap (4 tests, BRouter mocked in beforeEach) +- [x] 2.3 Create `e2e/planner-multiday.test.ts` — overnight toggle, multi-day GPX export (2 tests, BRouter mocked in beforeEach) +- [x] 2.4 Create `e2e/planner-overlays.test.ts` — hillshading tiles, POI category markers (2 tests) +- [x] 2.5 Create `e2e/planner-coloring.test.ts` — color mode, road type chart, hover label (3 tests, BRouter mocked in beforeEach) + +## 3. Clean up + +- [x] 3.1 Delete `e2e/planner.test.ts` diff --git a/openspec/specs/e2e-testing/spec.md b/openspec/specs/e2e-testing/spec.md new file mode 100644 index 0000000..fc89169 --- /dev/null +++ b/openspec/specs/e2e-testing/spec.md @@ -0,0 +1,16 @@ +## Requirements + +### Requirement: Planner E2E test infrastructure +The Planner E2E test suite SHALL mock BRouter by default, split tests by feature file, and provide shared test helpers for common setup patterns. + +#### Scenario: BRouter mocked by default +- **WHEN** a Planner E2E test runs +- **THEN** BRouter is mocked via a shared Playwright fixture unless the test explicitly opts into the real BRouter endpoint + +#### Scenario: Tests split by feature file +- **WHEN** the Planner E2E test suite is executed +- **THEN** tests are organized into focused files by feature area (session, routing, multi-day, overlays, coloring) instead of a single monolithic test file + +#### Scenario: Shared test helpers +- **WHEN** a Planner E2E test needs to create a session and wait for connection +- **THEN** it uses a shared helper function that handles session creation, navigation, and waiting for the connected state diff --git a/playwright.config.ts b/playwright.config.ts index 6e21e1b..99924cf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ }, { name: "planner", - testMatch: "planner.test.ts", + testMatch: "planner-*.test.ts", use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:3001",