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:
@@ -101,6 +101,33 @@ bun test
|
||||
Each test file creates and tears down its own server row (random token per run) so runs never
|
||||
collide with each other or with real dev data — see `api/src/test-helpers.ts`.
|
||||
|
||||
## Running the e2e suite
|
||||
|
||||
`bun test` above exercises `api` and `frontend` independently — it never proves the browser can
|
||||
actually reach both through the same origin the way production's Caddy routing does (`/ws*` and
|
||||
`/api/*` -> `api`, everything else -> `frontend`, see `Caddyfile`). `e2e/` is a standing
|
||||
Playwright suite that closes that gap: it drives a real Chromium browser against the full stack
|
||||
behind a small routing-equivalent proxy (`e2e/proxy.ts` — no `caddy` binary is available in this
|
||||
dev environment, so it isn't real Caddy, just the same three routing rules).
|
||||
|
||||
```
|
||||
cd e2e
|
||||
bun install
|
||||
bunx playwright install chromium # one-time, downloads the browser binary
|
||||
bunx playwright test
|
||||
```
|
||||
|
||||
`global-setup.ts` does everything by itself — no manual container/migration steps needed first
|
||||
(unlike the `bun test` section above): throwaway Postgres/Redis/MinIO containers
|
||||
(`mcmapper-e2e-*`, distinct names/ports from the `bun test` ones so both can run at once),
|
||||
migrations, a seeded server + linked account/session + a 5x5-chunk terrain footprint around the
|
||||
world origin, then the `api`/`frontend`/proxy processes. `global-teardown.ts` kills every spawned
|
||||
process and removes the containers afterward. Covers the two UI flows most worth a real
|
||||
click-through: the marker click-to-place/edit popup (`tests/markers.spec.ts`, including that a
|
||||
marker created while linked shows up in a second browser context with the same session — the
|
||||
cross-device sync claim) and the region-select drag + glTF export (`tests/region-export.spec.ts`,
|
||||
including a real triggered file download).
|
||||
|
||||
## Attribution
|
||||
|
||||
See `THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
.e2e-state.json
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@mcmapper/e2e",
|
||||
"dependencies": {
|
||||
"postgres": "^3.4.9",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^26.2.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
|
||||
|
||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Shared constants between global-setup.ts, global-teardown.ts, playwright.config.ts, and the
|
||||
// proxy process — kept in one place deliberately (see region-select.js's MAX_EXPORT_CHUNKS
|
||||
// comment for why hand-synced duplicate constants across files are worth avoiding when a single
|
||||
// source is this easy).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const ROOT = join(import.meta.dirname, "..");
|
||||
export const API_DIR = join(ROOT, "api");
|
||||
export const FRONTEND_DIR = join(ROOT, "frontend");
|
||||
|
||||
// Distinct container names/ports from the README's `bun test` throwaway infra (mcmapper-test-*
|
||||
// on 15432/16379/19000) so an e2e run and a `bun test` run can happen at the same time without
|
||||
// colliding.
|
||||
export const PG_CONTAINER = "mcmapper-e2e-pg";
|
||||
export const REDIS_CONTAINER = "mcmapper-e2e-redis";
|
||||
export const MINIO_CONTAINER = "mcmapper-e2e-minio";
|
||||
|
||||
export const PG_PORT = 15433;
|
||||
export const REDIS_PORT = 16380;
|
||||
export const MINIO_PORT = 19002;
|
||||
|
||||
export const API_PORT = 13010;
|
||||
export const FRONTEND_PORT = 13011;
|
||||
export const PROXY_PORT = 14010;
|
||||
|
||||
export const DATABASE_URL = `postgres://mcmapper:mcmapper@localhost:${PG_PORT}/mcmapper`;
|
||||
export const REDIS_URL = `redis://localhost:${REDIS_PORT}`;
|
||||
export const MINIO_ACCESS_KEY = "mcmapper";
|
||||
export const MINIO_SECRET_KEY = "mcmapper-e2e-only";
|
||||
|
||||
export const PROXY_ORIGIN = `http://localhost:${PROXY_PORT}`;
|
||||
|
||||
export const STATE_FILE = join(import.meta.dirname, ".e2e-state.json");
|
||||
|
||||
export type E2eState = {
|
||||
pids: number[];
|
||||
containers: string[];
|
||||
baseURL: string;
|
||||
serverId: string;
|
||||
serverToken: string;
|
||||
sessionToken: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
// Read by spec files — by the time tests run, global-setup.ts has already written this.
|
||||
export function readE2eState(): E2eState {
|
||||
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Kills every process global-setup.ts spawned and removes the throwaway containers it started.
|
||||
// Reads .e2e-state.json rather than relying on in-memory state from global-setup.ts, so teardown
|
||||
// is correct even if it's ever invoked independently — see global-setup.ts's doc comment on the
|
||||
// zombie-listener gotcha this is specifically guarding against.
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { STATE_FILE, type E2eState } from "./config";
|
||||
|
||||
function killPid(pid: number) {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
// shell:true spawned bun via cmd.exe — killing just the parent PID leaves the real bun
|
||||
// process running; /T kills the whole tree.
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
|
||||
} else {
|
||||
process.kill(-pid, "SIGKILL"); // negative pid: whole process group (spawned with shell)
|
||||
}
|
||||
} catch {
|
||||
// already dead — fine
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalTeardown() {
|
||||
if (!existsSync(STATE_FILE)) return;
|
||||
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf-8")) as E2eState;
|
||||
|
||||
for (const pid of state.pids) killPid(pid);
|
||||
|
||||
for (const container of state.containers) {
|
||||
try {
|
||||
execSync(`docker rm -f ${container}`, { stdio: "ignore" });
|
||||
} catch {
|
||||
// fine
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(STATE_FILE, { force: true });
|
||||
console.log("[global-teardown] cleaned up");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@mcmapper/e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^26.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "^3.4.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { PROXY_ORIGIN } from "./config";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false, // all tests share one seeded server/dataset — keep it simple, not racy
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
globalSetup: "./global-setup.ts",
|
||||
globalTeardown: "./global-teardown.ts",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: PROXY_ORIGIN,
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,123 @@
|
||||
// Covers the click-to-place/edit marker popup flow (Leaflet L.popup() + Alpine's $refs
|
||||
// DOM-node-reuse trick in map.js's init()/openMarkerForm) — flagged throughout MCMapper's
|
||||
// development as verified only at the unit-test/manual-curl level, never actually clicked
|
||||
// through in a real browser. See README's e2e section.
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { readE2eState } from "../config";
|
||||
|
||||
const state = readE2eState();
|
||||
const SESSION_STORAGE_KEY = "mcmapper_session";
|
||||
|
||||
async function waitForMapReady(page: Page) {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
|
||||
// Leaflet adds this class to the same #map div passed to L.map() — waiting for it means the
|
||||
// map/tile layer/view are fully initialized before we try to click on it.
|
||||
await page.locator("#map.leaflet-container").waitFor();
|
||||
}
|
||||
|
||||
async function placeMarkerAt(page: Page, offset: { x: number; y: number }) {
|
||||
await page.getByTestId("place-marker-toggle").click();
|
||||
const map = page.locator("#map");
|
||||
const box = await map.boundingBox();
|
||||
if (!box) throw new Error("#map has no bounding box");
|
||||
await map.click({ position: offset });
|
||||
// Not `.leaflet-popup` — Leaflet doesn't remove a previously-closed popup's container from the
|
||||
// DOM, only the shared markerFormEl content node (moved between popups, see map.js's
|
||||
// openMarkerForm doc comment) is guaranteed unique and reflects the currently-open popup.
|
||||
await expect(page.getByTestId("marker-form")).toBeVisible();
|
||||
return box;
|
||||
}
|
||||
|
||||
test.describe("marker popup — anonymous (localStorage)", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("clicking the map opens a popup pre-filled with defaults", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await placeMarkerAt(page, { x: 400, y: 300 });
|
||||
|
||||
await expect(page.getByTestId("marker-y")).toHaveValue("60");
|
||||
await expect(page.getByTestId("marker-x")).not.toHaveValue("");
|
||||
await expect(page.getByTestId("marker-z")).not.toHaveValue("");
|
||||
|
||||
const color = await page.getByTestId("marker-color-input").inputValue();
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
test("creating a marker adds it to the list and survives a reload", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await placeMarkerAt(page, { x: 420, y: 280 });
|
||||
|
||||
await page.getByTestId("marker-name-input").fill("Test Marker");
|
||||
await page.getByTestId("marker-save").click();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("editing a marker via the popup updates its name", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await placeMarkerAt(page, { x: 440, y: 260 });
|
||||
await page.getByTestId("marker-name-input").fill("Before Edit");
|
||||
await page.getByTestId("marker-save").click();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toBeVisible();
|
||||
|
||||
await page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]').getByTestId("marker-edit").click();
|
||||
await expect(page.getByTestId("marker-form")).toBeVisible();
|
||||
await expect(page.getByTestId("marker-name-input")).toHaveValue("Before Edit");
|
||||
|
||||
await page.getByTestId("marker-name-input").fill("After Edit");
|
||||
await page.getByTestId("marker-save").click();
|
||||
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="After Edit"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("deleting a marker removes it from the list", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await placeMarkerAt(page, { x: 460, y: 240 });
|
||||
await page.getByTestId("marker-name-input").fill("To Delete");
|
||||
await page.getByTestId("marker-save").click();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toBeVisible();
|
||||
|
||||
await page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]').getByTestId("marker-delete").click();
|
||||
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("marker popup — linked account (server-synced across devices)", () => {
|
||||
async function injectSession(page: Page) {
|
||||
await page.addInitScript(
|
||||
([key, token]) => localStorage.setItem(key, token),
|
||||
[SESSION_STORAGE_KEY, state.sessionToken],
|
||||
);
|
||||
}
|
||||
|
||||
test("a marker created while linked is visible in a separate browser context with the same session", async ({ browser }) => {
|
||||
const contextA = await browser.newContext();
|
||||
const pageA = await contextA.newPage();
|
||||
await injectSession(pageA);
|
||||
await waitForMapReady(pageA);
|
||||
await expect(pageA.getByTestId("account-linked")).toBeVisible();
|
||||
|
||||
await placeMarkerAt(pageA, { x: 400, y: 300 });
|
||||
const markerName = `Synced Marker ${Date.now()}`;
|
||||
await pageA.getByTestId("marker-name-input").fill(markerName);
|
||||
await pageA.getByTestId("marker-save").click();
|
||||
await expect(pageA.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
|
||||
await contextA.close();
|
||||
|
||||
// A fresh context has its own localStorage — the only way this marker can show up here is
|
||||
// if it was actually persisted server-side (accounts.markers table), proving the
|
||||
// cross-device sync the user asked for, not just localStorage.
|
||||
const contextB = await browser.newContext();
|
||||
const pageB = await contextB.newPage();
|
||||
await injectSession(pageB);
|
||||
await waitForMapReady(pageB);
|
||||
await expect(pageB.getByTestId("account-linked")).toBeVisible();
|
||||
await expect(pageB.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
|
||||
await contextB.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Covers the region-select drag tool (mousedown/mousemove/mouseup handlers wired in map.js's
|
||||
// startRegionSelect/updateRegionPreview/finishRegionSelect) and the client-side glTF export
|
||||
// pipeline (export-worker.js) — the second UI surface flagged throughout MCMapper's Phase 5 work
|
||||
// as verified only via a headless pipeline run against seeded data, never an actual browser
|
||||
// drag gesture. See README's e2e section.
|
||||
//
|
||||
// The seeded dataset (see global-setup.ts) fills a 5x5-chunk solid terrain footprint centered on
|
||||
// the world origin — where the map's default view is centered — so a drag anywhere near the
|
||||
// visible center reliably selects real, meshable terrain without computing exact Leaflet
|
||||
// pixel<->world math.
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
async function waitForMapReady(page: Page) {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("region-toggle")).toBeVisible();
|
||||
await page.locator("#map.leaflet-container").waitFor();
|
||||
}
|
||||
|
||||
async function dragOnMap(page: Page, fromFrac: number, toFrac: number) {
|
||||
const box = await page.locator("#map").boundingBox();
|
||||
if (!box) throw new Error("#map has no bounding box");
|
||||
const start = { x: box.x + box.width * fromFrac, y: box.y + box.height * fromFrac };
|
||||
const end = { x: box.x + box.width * toFrac, y: box.y + box.height * toFrac };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((start.x + end.x) / 2, (start.y + end.y) / 2, { steps: 5 });
|
||||
await page.mouse.move(end.x, end.y, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("dragging on the map selects a region and reports the chunk count", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await page.getByTestId("region-toggle").click();
|
||||
|
||||
await dragOnMap(page, 0.4, 0.6);
|
||||
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||
await expect(page.getByTestId("region-export")).toBeVisible();
|
||||
});
|
||||
|
||||
test("exporting a selected region downloads a non-empty .glb file", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
await page.getByTestId("region-toggle").click();
|
||||
await dragOnMap(page, 0.35, 0.65);
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByTestId("region-export").click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.glb$/);
|
||||
const path = await download.path();
|
||||
expect(path).not.toBeNull();
|
||||
const { statSync } = await import("node:fs");
|
||||
const size = statSync(path!).size;
|
||||
// A well-formed GLB has at minimum a 12-byte header + JSON chunk header — real seeded terrain
|
||||
// should produce something far larger (actual mesh geometry), so this also catches an
|
||||
// accidentally-empty export, not just a missing file.
|
||||
expect(size).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
test("an oversized selection is rejected with a clear message instead of exporting", async ({ page }) => {
|
||||
await waitForMapReady(page);
|
||||
|
||||
// Zoom out to the map's minimum (minZoom: -4, see map.js's init()) so many more chunks fit in
|
||||
// the same viewport, making it easy to drag past the 64-chunk cap without needing to compute
|
||||
// exact pixel<->world math. Leaflet's zoom-out control gains `leaflet-disabled` once at
|
||||
// minZoom — waiting for that (rather than a fixed click count) avoids racing the zoom
|
||||
// animation, which silently drops clicks fired faster than it can keep up.
|
||||
const zoomOut = page.locator(".leaflet-control-zoom-out");
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (await zoomOut.evaluate((el) => el.classList.contains("leaflet-disabled"))) break;
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.getByTestId("region-toggle").click();
|
||||
// Not 0.02 — that lands the mousedown on Leaflet's own top-left zoom control, which stops
|
||||
// event propagation before it reaches the map's mousedown listener (startRegionSelect never
|
||||
// fires). Starting past that corner still covers a huge area at minZoom.
|
||||
await dragOnMap(page, 0.15, 0.95);
|
||||
|
||||
await expect(page.getByTestId("region-status")).toHaveText(/too large/);
|
||||
// x-show hides via CSS rather than removing the node, so assert visibility, not DOM count.
|
||||
await expect(page.getByTestId("region-export")).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "@playwright/test"]
|
||||
}
|
||||
}
|
||||
@@ -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