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
+29 -20
View File
@@ -6,8 +6,15 @@ html(lang="en")
title MCMapper
link(rel="stylesheet" href="/css/tailwind.css")
link(rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css")
script(defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js")
script(src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js")
//- Classic `defer` scripts and `type="module"` scripts (always deferred) share one
//- document-order execution queue, so this module script — which assigns
//- `window.mapmapper`, the function Alpine's `x-data="mapmapper()"` needs — must appear
//- before Alpine's own `defer` tag below, or Alpine auto-inits and evaluates `x-data`
//- against an undefined `mapmapper` (caught by e2e/tests/markers.spec.ts, which failed with
//- `mapmapper is not defined` on this exact race before this reordering).
script(type="module" src="/js/map.js")
script(defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js")
style.
html, body, #map { height: 100%; margin: 0; }
body.bg-neutral-900.text-neutral-100
@@ -23,24 +30,24 @@ html(lang="en")
div.flex-1.flex.overflow-hidden
div#map.flex-1
div(style="display:none")
div(x-ref="markerFormEl")
div(x-ref="markerFormEl" data-testid="marker-form")
div.bg-neutral-800.text-neutral-100.rounded.p-2.space-y-2(style="min-width: 220px;")
p.text-xs.text-neutral-400(x-text="markerForm.mode === 'edit' ? 'Edit marker' : 'New marker'")
div.grid.grid-cols-3.gap-1
input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700(
type="number" x-model.number="markerForm.x" placeholder="x")
type="number" x-model.number="markerForm.x" placeholder="x" data-testid="marker-x")
input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700(
type="number" x-model.number="markerForm.y" placeholder="y")
type="number" x-model.number="markerForm.y" placeholder="y" data-testid="marker-y")
input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700(
type="number" x-model.number="markerForm.z" placeholder="z")
type="number" x-model.number="markerForm.z" placeholder="z" data-testid="marker-z")
div.flex.gap-1
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="marker name" x-model="markerForm.name"
x-on:keydown.enter="saveMarkerForm")
input.w-10(type="color" x-model="markerForm.color")
x-on:keydown.enter="saveMarkerForm" data-testid="marker-name-input")
input.w-10(type="color" x-model="markerForm.color" data-testid="marker-color-input")
div.flex.gap-1
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveMarkerForm") Save
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm") Cancel
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveMarkerForm" data-testid="marker-save") Save
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm" data-testid="marker-cancel") Cancel
p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus")
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
div.border-b.border-neutral-700.p-2.space-y-2
@@ -49,13 +56,15 @@ html(lang="en")
button.px-2.py-1.rounded.text-xs(
x-bind:class="selectingRegion ? 'bg-amber-600' : 'bg-neutral-700'"
x-on:click="toggleSelectingRegion"
x-text="selectingRegion ? 'drag on map…' : '+ select region'")
p.text-xs.text-neutral-400(x-show="regionStatus" x-text="regionStatus")
x-text="selectingRegion ? 'drag on map…' : '+ select region'"
data-testid="region-toggle")
p.text-xs.text-neutral-400(x-show="regionStatus" x-text="regionStatus" data-testid="region-status")
div.flex.gap-1(x-show="regionBounds")
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(
x-bind:disabled="exporting" x-on:click="exportRegion"
x-text="exporting ? 'exporting…' : 'Export glTF'")
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelRegionSelection") Cancel
x-text="exporting ? 'exporting…' : 'Export glTF'"
data-testid="region-export")
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelRegionSelection" data-testid="region-cancel") Cancel
div.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;")
div.flex.items-center.justify-between
@@ -63,16 +72,17 @@ html(lang="en")
button.px-2.py-1.rounded.text-xs(
x-bind:class="placingMarker ? 'bg-amber-600' : 'bg-neutral-700'"
x-on:click="togglePlacingMarker"
x-text="placingMarker ? 'click the map…' : '+ place marker'")
x-text="placingMarker ? 'click the map…' : '+ place marker'"
data-testid="place-marker-toggle")
template(x-for="marker in markers" x-bind:key="marker.id")
div.flex.items-center.gap-2.text-sm
div.flex.items-center.gap-2.text-sm(data-testid="marker-row" x-bind:data-marker-name="marker.name")
span.inline-block.w-3.h-3.rounded-full.flex-shrink-0(x-bind:style="'background:' + marker.color")
span.flex-1.truncate(x-text="marker.name")
span.text-xs.text-neutral-500(x-text="marker.x + ', ' + marker.y + ', ' + marker.z")
button.text-xs.underline(x-on:click="editMarker(marker)") edit
button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)") share
button.text-xs.text-red-400(x-on:click="deleteMarker(marker)") ×
button.text-xs.underline(x-on:click="editMarker(marker)" data-testid="marker-edit") edit
button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)" data-testid="marker-share") share
button.text-xs.text-red-400(x-on:click="deleteMarker(marker)" data-testid="marker-delete") ×
div.flex-1.overflow-y-auto.p-2.space-y-1(x-ref="chatLog")
template(x-for="msg in chatMessages" x-bind:key="msg.id")
@@ -84,7 +94,7 @@ html(lang="en")
div.p-2.border-t.border-neutral-700.space-y-2
template(x-if="account")
div.text-xs.text-neutral-400.flex.items-center.gap-2
div.text-xs.text-neutral-400.flex.items-center.gap-2(data-testid="account-linked")
span
| Linked as
span.font-semibold.text-neutral-200(x-text="' ' + account.username")
@@ -108,4 +118,3 @@ html(lang="en")
button.px-2.py-1.bg-neutral-700.rounded.text-sm(x-on:click="redeemLink") Link
p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus")
script(type="module" src="/js/map.js")