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
+178
View File
@@ -0,0 +1,178 @@
// Orchestrates everything a real browser session needs, mirroring production's Caddy-routed
// single-origin setup (see proxy.ts) since that routing contract was never actually exercised by
// any of this project's prior manual verification — see README's e2e section.
//
// Uses globalSetup/globalTeardown (manual orchestration) rather than Playwright's built-in
// `webServer` option: the proxy has no independent readiness signal until api+frontend are
// already listening, which `webServer`'s single command-per-entry model doesn't fit well.
//
// No `caddy` binary is available in this dev environment, hence the hand-rolled proxy.ts instead
// of running real Caddy.
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { writeFileSync } from "node:fs";
import postgres from "postgres";
import {
API_DIR,
API_PORT,
DATABASE_URL,
FRONTEND_DIR,
FRONTEND_PORT,
MINIO_ACCESS_KEY,
MINIO_CONTAINER,
MINIO_PORT,
MINIO_SECRET_KEY,
PG_CONTAINER,
PG_PORT,
PROXY_ORIGIN,
PROXY_PORT,
REDIS_CONTAINER,
REDIS_PORT,
REDIS_URL,
ROOT,
STATE_FILE,
} from "./config";
function tryRemoveContainer(name: string) {
try {
execSync(`docker rm -f ${name}`, { stdio: "ignore" });
} catch {
// fine — container didn't exist
}
}
async function waitFor(check: () => Promise<boolean>, label: string, timeoutMs = 45000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await check().catch(() => false)) return;
await new Promise((r) => setTimeout(r, 300));
}
throw new Error(`timed out waiting for ${label} to become ready`);
}
async function httpOk(url: string) {
const res = await fetch(url);
return res.ok;
}
const children: ChildProcess[] = [];
// Windows/Bun sometimes double-binds a port across process restarts (see MCMapper's project
// memory) — tracking every spawned PID explicitly and killing them all in global-teardown.ts
// avoids that class of zombie-listener bug for these throwaway dev-server instances.
function spawnTracked(label: string, cmd: string, args: string[], cwd: string, env: Record<string, string>) {
const child = spawn(cmd, args, {
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
// Posix: makes the child its own process-group leader so global-teardown.ts can kill
// `-pid` (the whole group, including any grandchildren) instead of just this one process.
detached: process.platform !== "win32",
});
child.stdout?.on("data", (d) => process.stdout.write(`[${label}] ${d}`));
child.stderr?.on("data", (d) => process.stderr.write(`[${label}] ${d}`));
children.push(child);
return child;
}
function encodeSolidSection(blockIdMeta: number): string {
const bytes = new Uint8Array(4096 * 2);
const view = new DataView(bytes.buffer);
for (let i = 0; i < 4096; i++) view.setUint16(i * 2, blockIdMeta, true);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
export default async function globalSetup() {
tryRemoveContainer(PG_CONTAINER);
tryRemoveContainer(REDIS_CONTAINER);
tryRemoveContainer(MINIO_CONTAINER);
execSync(
`docker run -d --name ${PG_CONTAINER} -e POSTGRES_USER=mcmapper -e POSTGRES_PASSWORD=mcmapper -e POSTGRES_DB=mcmapper -p ${PG_PORT}:5432 postgres:17-alpine`,
{ stdio: "inherit" },
);
execSync(`docker run -d --name ${REDIS_CONTAINER} -p ${REDIS_PORT}:6379 redis:7-alpine`, { stdio: "inherit" });
execSync(
`docker run -d --name ${MINIO_CONTAINER} -e MINIO_ROOT_USER=${MINIO_ACCESS_KEY} -e MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} -p ${MINIO_PORT}:9000 minio/minio:latest server /data`,
{ stdio: "inherit" },
);
const sql = postgres(DATABASE_URL);
await waitFor(async () => {
await sql`select 1`;
return true;
}, "postgres");
execSync("bun run migrate", { cwd: API_DIR, stdio: "inherit", env: { ...process.env, DATABASE_URL } });
// Seed a server + a linked account/session (so markers.spec.ts can exercise the server-synced
// marker path, not just the anonymous/localStorage one) + a 5x5-chunk solid terrain footprint
// centered on the world origin, where the map's default view (`setView([0,0],0)`) is centered
// — generous enough that a drag anywhere near the visible center reliably selects real,
// meshable terrain without the test needing to compute exact Leaflet pixel<->world math.
const serverToken = `e2e-${crypto.randomUUID()}`;
const [server] = await sql<{ id: string }[]>`
insert into servers (name, token) values ('e2e-server', ${serverToken}) returning id
`;
const [account] = await sql<{ id: string }[]>`
insert into accounts (mc_uuid, username, auth_mode)
values (${"e2e-" + crypto.randomUUID()}, 'e2e-player', 'online')
returning id
`;
const sessionToken = `e2e-session-${crypto.randomUUID()}`;
await sql`insert into sessions (token, account_id) values (${sessionToken}, ${account!.id})`;
const blocks = encodeSolidSection((2 << 4) | 0); // grass
for (let x = -2; x <= 2; x++) {
for (let z = -2; z <= 2; z++) {
await sql`
insert into chunk_sections (server_id, dimension, x, z, section_y, blocks)
values (${server!.id}, 0, ${x}, ${z}, 3, ${blocks})
`;
}
}
await sql.end();
spawnTracked("api", "bun", ["run", "src/index.ts"], API_DIR, {
PORT: String(API_PORT),
DATABASE_URL,
REDIS_URL,
MINIO_ENDPOINT: "localhost",
MINIO_PORT: String(MINIO_PORT),
MINIO_USE_SSL: "false",
MINIO_ACCESS_KEY,
MINIO_SECRET_KEY,
});
await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api");
spawnTracked("frontend", "bun", ["run", "src/index.ts"], FRONTEND_DIR, {
PORT: String(FRONTEND_PORT),
API_URL: `http://localhost:${API_PORT}`,
});
await waitFor(() => httpOk(`http://localhost:${FRONTEND_PORT}/health`), "frontend");
spawnTracked("proxy", "bun", ["run", "proxy.ts"], ROOT + "/e2e", {
E2E_PROXY_PORT: String(PROXY_PORT),
E2E_API_ORIGIN: `http://localhost:${API_PORT}`,
E2E_FRONTEND_ORIGIN: `http://localhost:${FRONTEND_PORT}`,
});
await waitFor(() => httpOk(`${PROXY_ORIGIN}/__proxy_health`), "proxy");
writeFileSync(
STATE_FILE,
JSON.stringify({
pids: children.map((c) => c.pid).filter((pid): pid is number => pid !== undefined),
containers: [PG_CONTAINER, REDIS_CONTAINER, MINIO_CONTAINER],
baseURL: PROXY_ORIGIN,
serverId: server!.id,
serverToken,
sessionToken,
accountId: account!.id,
}),
);
console.log("[global-setup] ready:", PROXY_ORIGIN);
}