Split planner E2E tests into focused feature files

Breaks the monolithic planner.test.ts (581 lines, 25 tests) into five
focused files grouped by feature area, with BRouter mocked by default
via test.beforeEach in files that need routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-22 21:58:10 +02:00
parent 72c01eb18c
commit 0b146f9b32
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
14 changed files with 584 additions and 582 deletions

12
e2e/helpers/planner.ts Normal file
View file

@ -0,0 +1,12 @@
import type { APIRequestContext, Page } from "@playwright/test";
export async function createSession(request: APIRequestContext): Promise<string> {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
return url;
}
export async function openSession(page: Page, url: string): Promise<void> {
await page.goto(url);
await page.getByText("Connected").waitFor({ timeout: 15000 });
}

View file

@ -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();
});
});

View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.520" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="52.516" lon="13.377"><name>Mitte</name><type>overnight</type></wpt>
<wpt lat="52.510" lon="13.390"><name>Kreuzberg</name></wpt>
<trk><trkseg>
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.516" lon="13.377"><ele>40</ele></trkpt>
<trkpt lat="52.510" lon="13.390"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
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 });
});
});

View file

@ -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<HTMLInputElement>(".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();
});
});

153
e2e/planner-routing.test.ts Normal file
View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.52" lon="13.405"><name>Berlin</name><desc>Refill water here</desc></wpt>
<wpt lat="52.515" lon="13.351"><name>Spandau</name></wpt>
<trk><trkseg>
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.515" lon="13.351"><ele>40</ele></trkpt>
</trkseg></trk>
</gpx>`;
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("<desc>Refill water here</desc>");
expect(gpxText.indexOf("<wpt")).toBeLessThan(gpxText.indexOf("<desc>Refill water here</desc>"));
});
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");
});
});

132
e2e/planner-session.test.ts Normal file
View file

@ -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: '<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt><wpt lat="48.137" lon="11.576"><name>Munich</name></wpt></gpx>',
},
});
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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg></trk>
</gpx>`;
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 });
});
});

View file

@ -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: '<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt><wpt lat="48.137" lon="11.576"><name>Munich</name></wpt></gpx>',
},
});
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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg></trk>
</gpx>`;
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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.520" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="52.516" lon="13.377"><name>Mitte</name><type>overnight</type></wpt>
<wpt lat="52.510" lon="13.390"><name>Kreuzberg</name></wpt>
<trk><trkseg>
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.516" lon="13.377"><ele>40</ele></trkpt>
<trkpt lat="52.510" lon="13.390"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
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<HTMLInputElement>(".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 <desc> on a <wpt> onto the map
const gpxWithNote = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.52" lon="13.405"><name>Berlin</name><desc>Refill water here</desc></wpt>
<wpt lat="52.515" lon="13.351"><name>Spandau</name></wpt>
<trk><trkseg>
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.515" lon="13.351"><ele>40</ele></trkpt>
</trkseg></trk>
</gpx>`;
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("<desc>Refill water here</desc>");
expect(gpxText.indexOf("<wpt")).toBeLessThan(gpxText.indexOf("<desc>Refill water here</desc>"));
});
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");
});
});

View file

@ -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<void>` (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.

View file

@ -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`

View file

@ -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

View file

@ -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",