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:
2026-08-09 12:34:12 +02:00
parent c78661efb5
commit 7b85f4dff1
14 changed files with 704 additions and 21 deletions
+6 -1
View File
@@ -290,7 +290,12 @@ function mapmapper() {
this.regionStatus = err.message ?? "export_failed";
worker.terminate();
};
worker.postMessage({ serverId: this.server.id, dimension: 0, bounds: this.regionBounds });
// Alpine wraps x-data state (including nested objects assigned into it, like
// regionBounds) in reactive Proxies, which the structured clone algorithm postMessage
// uses can't clone — spread into a plain object first (caught by
// e2e/tests/region-export.spec.ts's real-browser export flow; a headless pipeline run
// against the same functions outside of Alpine never hits this, since there's no Proxy).
worker.postMessage({ serverId: this.server.id, dimension: 0, bounds: { ...this.regionBounds } });
},
localMarkerStorageKey() {