Merge pull request #395 from trails-cool/stigi/e2e-planner-split
Split planner E2E tests into focused feature files
This commit is contained in:
commit
08449b01c9
14 changed files with 588 additions and 582 deletions
12
e2e/helpers/planner.ts
Normal file
12
e2e/helpers/planner.ts
Normal 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 });
|
||||
}
|
||||
71
e2e/planner-coloring.test.ts
Normal file
71
e2e/planner-coloring.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
e2e/planner-multiday.test.ts
Normal file
71
e2e/planner-multiday.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
72
e2e/planner-overlays.test.ts
Normal file
72
e2e/planner-overlays.test.ts
Normal 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
153
e2e/planner-routing.test.ts
Normal 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
132
e2e/planner-session.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
20
openspec/specs/e2e-testing/spec.md
Normal file
20
openspec/specs/e2e-testing/spec.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
## Purpose
|
||||
|
||||
Define the structure and conventions for the E2E test suite so tests are maintainable, deterministic, and easy to extend.
|
||||
|
||||
## 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue