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
+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"]
}
}