Files
MCMapper-Backend/e2e/tests/markers.spec.ts
T
octoturge 7b85f4dff1 Add standing Playwright e2e suite; fix two real bugs it caught
The whole point of driving a real browser instead of curling api/frontend
separately: none of this session's prior "live verification" ever exercised
the same-origin routing production relies on (Caddy: /ws*+/api/* -> api,
else -> frontend), so a real browser's relative fetch()/WebSocket calls were
never actually proven to resolve. e2e/proxy.ts mirrors that routing (no
caddy binary available locally); global-setup.ts/global-teardown.ts
orchestrate throwaway infra + seeded data + the api/frontend/proxy
processes end to end.

Getting the suite green surfaced two genuine bugs invisible to unit tests:
- index.pug loaded map.js via two <script type="module"> tags (one moved to
  <head> to fix load-order, the original left in place by mistake), causing
  Alpine's x-init="init()" to run twice and Leaflet to throw "Map container
  is already initialized" on the second call.
- map.js's exportRegion() passed the Alpine-reactive `regionBounds` object
  straight into worker.postMessage(); Alpine wraps assigned state in
  Proxies, which the structured clone algorithm can't clone, so every
  export silently failed. Fixed by spreading into a plain object first.

Covers the two flows flagged all session as verified only at the unit/curl
level: the marker click-to-place/edit popup (including that a marker
created while linked shows up in a second browser context with the same
session, proving server-side sync) and the region-select drag + glTF
export (including a real triggered file download).
2026-08-09 12:34:12 +02:00

124 lines
5.9 KiB
TypeScript

// Covers the click-to-place/edit marker popup flow (Leaflet L.popup() + Alpine's $refs
// DOM-node-reuse trick in map.js's init()/openMarkerForm) — flagged throughout MCMapper's
// development as verified only at the unit-test/manual-curl level, never actually clicked
// through in a real browser. See README's e2e section.
import { test, expect, type Page } from "@playwright/test";
import { readE2eState } from "../config";
const state = readE2eState();
const SESSION_STORAGE_KEY = "mcmapper_session";
async function waitForMapReady(page: Page) {
await page.goto("/");
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
// Leaflet adds this class to the same #map div passed to L.map() — waiting for it means the
// map/tile layer/view are fully initialized before we try to click on it.
await page.locator("#map.leaflet-container").waitFor();
}
async function placeMarkerAt(page: Page, offset: { x: number; y: number }) {
await page.getByTestId("place-marker-toggle").click();
const map = page.locator("#map");
const box = await map.boundingBox();
if (!box) throw new Error("#map has no bounding box");
await map.click({ position: offset });
// Not `.leaflet-popup` — Leaflet doesn't remove a previously-closed popup's container from the
// DOM, only the shared markerFormEl content node (moved between popups, see map.js's
// openMarkerForm doc comment) is guaranteed unique and reflects the currently-open popup.
await expect(page.getByTestId("marker-form")).toBeVisible();
return box;
}
test.describe("marker popup — anonymous (localStorage)", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("clicking the map opens a popup pre-filled with defaults", async ({ page }) => {
await waitForMapReady(page);
await placeMarkerAt(page, { x: 400, y: 300 });
await expect(page.getByTestId("marker-y")).toHaveValue("60");
await expect(page.getByTestId("marker-x")).not.toHaveValue("");
await expect(page.getByTestId("marker-z")).not.toHaveValue("");
const color = await page.getByTestId("marker-color-input").inputValue();
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
});
test("creating a marker adds it to the list and survives a reload", async ({ page }) => {
await waitForMapReady(page);
await placeMarkerAt(page, { x: 420, y: 280 });
await page.getByTestId("marker-name-input").fill("Test Marker");
await page.getByTestId("marker-save").click();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
await page.reload();
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
});
test("editing a marker via the popup updates its name", async ({ page }) => {
await waitForMapReady(page);
await placeMarkerAt(page, { x: 440, y: 260 });
await page.getByTestId("marker-name-input").fill("Before Edit");
await page.getByTestId("marker-save").click();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toBeVisible();
await page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]').getByTestId("marker-edit").click();
await expect(page.getByTestId("marker-form")).toBeVisible();
await expect(page.getByTestId("marker-name-input")).toHaveValue("Before Edit");
await page.getByTestId("marker-name-input").fill("After Edit");
await page.getByTestId("marker-save").click();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="After Edit"]')).toBeVisible();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toHaveCount(0);
});
test("deleting a marker removes it from the list", async ({ page }) => {
await waitForMapReady(page);
await placeMarkerAt(page, { x: 460, y: 240 });
await page.getByTestId("marker-name-input").fill("To Delete");
await page.getByTestId("marker-save").click();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toBeVisible();
await page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]').getByTestId("marker-delete").click();
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toHaveCount(0);
});
});
test.describe("marker popup — linked account (server-synced across devices)", () => {
async function injectSession(page: Page) {
await page.addInitScript(
([key, token]) => localStorage.setItem(key, token),
[SESSION_STORAGE_KEY, state.sessionToken],
);
}
test("a marker created while linked is visible in a separate browser context with the same session", async ({ browser }) => {
const contextA = await browser.newContext();
const pageA = await contextA.newPage();
await injectSession(pageA);
await waitForMapReady(pageA);
await expect(pageA.getByTestId("account-linked")).toBeVisible();
await placeMarkerAt(pageA, { x: 400, y: 300 });
const markerName = `Synced Marker ${Date.now()}`;
await pageA.getByTestId("marker-name-input").fill(markerName);
await pageA.getByTestId("marker-save").click();
await expect(pageA.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
await contextA.close();
// A fresh context has its own localStorage — the only way this marker can show up here is
// if it was actually persisted server-side (accounts.markers table), proving the
// cross-device sync the user asked for, not just localStorage.
const contextB = await browser.newContext();
const pageB = await contextB.newPage();
await injectSession(pageB);
await waitForMapReady(pageB);
await expect(pageB.getByTestId("account-linked")).toBeVisible();
await expect(pageB.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
await contextB.close();
});
});