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:
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user