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
+27
View File
@@ -101,6 +101,33 @@ bun test
Each test file creates and tears down its own server row (random token per run) so runs never 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`. 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 ## Attribution
See `THIRD_PARTY_NOTICES.md`. See `THIRD_PARTY_NOTICES.md`.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
playwright/.cache/
.e2e-state.json
+31
View File
@@ -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=="],
}
}
+49
View File
@@ -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"));
}
+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);
}
+40
View File
@@ -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");
}
+16
View File
@@ -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"
}
}
+23
View File
@@ -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"] },
},
],
});
+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}`);
+123
View File
@@ -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();
});
});
+87
View File
@@ -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();
});
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "@playwright/test"]
}
}
+6 -1
View File
@@ -290,7 +290,12 @@ function mapmapper() {
this.regionStatus = err.message ?? "export_failed"; this.regionStatus = err.message ?? "export_failed";
worker.terminate(); 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() { localMarkerStorageKey() {
+29 -20
View File
@@ -6,8 +6,15 @@ html(lang="en")
title MCMapper title MCMapper
link(rel="stylesheet" href="/css/tailwind.css") link(rel="stylesheet" href="/css/tailwind.css")
link(rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.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") 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. style.
html, body, #map { height: 100%; margin: 0; } html, body, #map { height: 100%; margin: 0; }
body.bg-neutral-900.text-neutral-100 body.bg-neutral-900.text-neutral-100
@@ -23,24 +30,24 @@ html(lang="en")
div.flex-1.flex.overflow-hidden div.flex-1.flex.overflow-hidden
div#map.flex-1 div#map.flex-1
div(style="display:none") 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;") 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'") p.text-xs.text-neutral-400(x-text="markerForm.mode === 'edit' ? 'Edit marker' : 'New marker'")
div.grid.grid-cols-3.gap-1 div.grid.grid-cols-3.gap-1
input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700( 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( 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( 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 div.flex.gap-1
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700( 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" type="text" placeholder="marker name" x-model="markerForm.name"
x-on:keydown.enter="saveMarkerForm") x-on:keydown.enter="saveMarkerForm" data-testid="marker-name-input")
input.w-10(type="color" x-model="markerForm.color") input.w-10(type="color" x-model="markerForm.color" data-testid="marker-color-input")
div.flex.gap-1 div.flex.gap-1
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveMarkerForm") Save 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") Cancel 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") 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") 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 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( button.px-2.py-1.rounded.text-xs(
x-bind:class="selectingRegion ? 'bg-amber-600' : 'bg-neutral-700'" x-bind:class="selectingRegion ? 'bg-amber-600' : 'bg-neutral-700'"
x-on:click="toggleSelectingRegion" x-on:click="toggleSelectingRegion"
x-text="selectingRegion ? 'drag on map…' : '+ select region'") x-text="selectingRegion ? 'drag on map…' : '+ select region'"
p.text-xs.text-neutral-400(x-show="regionStatus" x-text="regionStatus") 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") div.flex.gap-1(x-show="regionBounds")
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs( button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(
x-bind:disabled="exporting" x-on:click="exportRegion" x-bind:disabled="exporting" x-on:click="exportRegion"
x-text="exporting ? 'exporting…' : 'Export glTF'") x-text="exporting ? 'exporting…' : 'Export glTF'"
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelRegionSelection") Cancel 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.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;")
div.flex.items-center.justify-between div.flex.items-center.justify-between
@@ -63,16 +72,17 @@ html(lang="en")
button.px-2.py-1.rounded.text-xs( button.px-2.py-1.rounded.text-xs(
x-bind:class="placingMarker ? 'bg-amber-600' : 'bg-neutral-700'" x-bind:class="placingMarker ? 'bg-amber-600' : 'bg-neutral-700'"
x-on:click="togglePlacingMarker" 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") 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.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.flex-1.truncate(x-text="marker.name")
span.text-xs.text-neutral-500(x-text="marker.x + ', ' + marker.y + ', ' + marker.z") 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-on:click="editMarker(marker)" data-testid="marker-edit") edit
button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)") share 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)") × 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") 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") 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 div.p-2.border-t.border-neutral-700.space-y-2
template(x-if="account") 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 span
| Linked as | Linked as
span.font-semibold.text-neutral-200(x-text="' ' + account.username") 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 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") p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus")
script(type="module" src="/js/map.js")