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).
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// Covers the region-select drag tool (mousedown/mousemove/mouseup handlers wired in map.js's
|
||||
// startRegionSelect/updateRegionPreview/finishRegionSelect) and the client-side glTF export
|
||||
// pipeline (export-worker.js) — the second UI surface flagged throughout MCMapper's Phase 5 work
|
||||
// as verified only via a headless pipeline run against seeded data, never an actual browser
|
||||
// drag gesture. See README's e2e section.
|
||||
//
|
||||
// The seeded dataset (see global-setup.ts) fills a 5x5-chunk solid terrain footprint centered on
|
||||
// the world origin — where the map's default view is centered — so a drag anywhere near the
|
||||
// visible center reliably selects real, meshable terrain without computing exact Leaflet
|
||||
// pixel<->world math.
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
async function waitForMapReady(page: Page) {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("region-toggle")).toBeVisible();
|
||||
await page.locator("#map.leaflet-container").waitFor();
|
||||
}
|
||||
|
||||
async function dragOnMap(page: Page, fromFrac: number, toFrac: number) {
|
||||
const box = await page.locator("#map").boundingBox();
|
||||
if (!box) throw new Error("#map has no bounding box");
|
||||
const start = { x: box.x + box.width * fromFrac, y: box.y + box.height * fromFrac };
|
||||
const end = { x: box.x + box.width * toFrac, y: box.y + box.height * toFrac };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((start.x + end.x) / 2, (start.y + end.y) / 2, { steps: 5 });
|
||||
await page.mouse.move(end.x, end.y, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("dragging on the map selects a region and reports the chunk count", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await page.getByTestId("region-toggle").click();
|
||||
|
||||
await dragOnMap(page, 0.4, 0.6);
|
||||
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||
await expect(page.getByTestId("region-export")).toBeVisible();
|
||||
});
|
||||
|
||||
test("exporting a selected region downloads a non-empty .glb file", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await page.getByTestId("region-toggle").click();
|
||||
await dragOnMap(page, 0.35, 0.65);
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByTestId("region-export").click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.glb$/);
|
||||
const path = await download.path();
|
||||
expect(path).not.toBeNull();
|
||||
const { statSync } = await import("node:fs");
|
||||
const size = statSync(path!).size;
|
||||
// A well-formed GLB has at minimum a 12-byte header + JSON chunk header — real seeded terrain
|
||||
// should produce something far larger (actual mesh geometry), so this also catches an
|
||||
// accidentally-empty export, not just a missing file.
|
||||
expect(size).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
test("an oversized selection is rejected with a clear message instead of exporting", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
|
||||
// Zoom out to the map's minimum (minZoom: -4, see map.js's init()) so many more chunks fit in
|
||||
// the same viewport, making it easy to drag past the 64-chunk cap without needing to compute
|
||||
// exact pixel<->world math. Leaflet's zoom-out control gains `leaflet-disabled` once at
|
||||
// minZoom — waiting for that (rather than a fixed click count) avoids racing the zoom
|
||||
// animation, which silently drops clicks fired faster than it can keep up.
|
||||
const zoomOut = page.locator(".leaflet-control-zoom-out");
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (await zoomOut.evaluate((el) => el.classList.contains("leaflet-disabled"))) break;
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.getByTestId("region-toggle").click();
|
||||
// Not 0.02 — that lands the mousedown on Leaflet's own top-left zoom control, which stops
|
||||
// event propagation before it reaches the map's mousedown listener (startRegionSelect never
|
||||
// fires). Starting past that corner still covers a huge area at minZoom.
|
||||
await dragOnMap(page, 0.15, 0.95);
|
||||
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/too large/);
|
||||
// x-show hides via CSS rather than removing the node, so assert visibility, not DOM count.
|
||||
await expect(page.getByTestId("region-export")).not.toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user