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
+79
View File
@@ -0,0 +1,79 @@
// Minimal reverse proxy mirroring the production Caddyfile's routing rules, used only for
// Playwright e2e runs — no `caddy` binary is available in this dev environment (see
// global-setup.ts). Routing matches Caddyfile exactly: /ws* and /api/* -> api, everything else
// -> frontend. This matters because the frontend's browser JS (map.js) makes same-origin
// relative calls (`fetch("/api/...")`, `new WebSocket(".../ws/chat/...")`) that only resolve
// correctly behind a single proxied origin like this one — see the README's e2e section for why
// that's the whole point of testing through this proxy instead of hitting api/frontend directly.
const PORT = Number(process.env.E2E_PROXY_PORT);
const API_ORIGIN = process.env.E2E_API_ORIGIN!; // e.g. http://localhost:13010
const FRONTEND_ORIGIN = process.env.E2E_FRONTEND_ORIGIN!; // e.g. http://localhost:13011
function targetOrigin(pathname: string): string {
if (pathname.startsWith("/ws") || pathname.startsWith("/api/")) return API_ORIGIN;
return FRONTEND_ORIGIN;
}
type WsData = {
targetUrl: string;
upstream?: WebSocket;
queue: (string | ArrayBuffer)[];
};
Bun.serve<WsData, {}>({
port: PORT,
async fetch(req, server) {
const url = new URL(req.url);
// Synthetic, non-forwarded endpoint so global-setup can wait for *this* process specifically
// (the one tests actually talk to) instead of inferring proxy readiness from api/frontend's
// own health checks, which would leave a chicken-and-egg gap.
if (url.pathname === "/__proxy_health") {
return new Response("ok");
}
const origin = targetOrigin(url.pathname);
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const targetUrl = origin.replace(/^http/, "ws") + url.pathname + url.search;
const upgraded = server.upgrade(req, { data: { targetUrl, queue: [] } });
return upgraded ? undefined : new Response("upgrade failed", { status: 400 });
}
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("content-length");
const res = await fetch(origin + url.pathname + url.search, {
method: req.method,
headers,
body: ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer(),
});
const resHeaders = new Headers(res.headers);
resHeaders.delete("content-encoding"); // fetch() already decoded the body
return new Response(res.body, { status: res.status, headers: resHeaders });
},
websocket: {
open(ws) {
const upstream = new WebSocket(ws.data.targetUrl);
ws.data.upstream = upstream;
upstream.onopen = () => {
for (const msg of ws.data.queue) upstream.send(msg as any);
ws.data.queue.length = 0;
};
upstream.onmessage = (ev) => ws.send(ev.data);
upstream.onclose = () => ws.close();
},
message(ws, message) {
const upstream = ws.data.upstream;
if (upstream && upstream.readyState === WebSocket.OPEN) upstream.send(message as any);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.upstream?.close();
},
},
});
console.log(`[e2e-proxy] listening on :${PORT} -> api=${API_ORIGIN} frontend=${FRONTEND_ORIGIN}`);