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,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}`);
|
||||
Reference in New Issue
Block a user