diff --git a/README.md b/README.md index a186b36..3f7d1e3 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..66fe9cd --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +playwright/.cache/ +.e2e-state.json diff --git a/e2e/bun.lock b/e2e/bun.lock new file mode 100644 index 0000000..491a002 --- /dev/null +++ b/e2e/bun.lock @@ -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=="], + } +} diff --git a/e2e/config.ts b/e2e/config.ts new file mode 100644 index 0000000..56e57c6 --- /dev/null +++ b/e2e/config.ts @@ -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")); +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..4c6df9b --- /dev/null +++ b/e2e/global-setup.ts @@ -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, 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) { + 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); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..c0d10f3 --- /dev/null +++ b/e2e/global-teardown.ts @@ -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"); +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..fb85a8a --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..bc9ef1f --- /dev/null +++ b/e2e/playwright.config.ts @@ -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"] }, + }, + ], +}); diff --git a/e2e/proxy.ts b/e2e/proxy.ts new file mode 100644 index 0000000..a2e2ccd --- /dev/null +++ b/e2e/proxy.ts @@ -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({ + 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}`); diff --git a/e2e/tests/markers.spec.ts b/e2e/tests/markers.spec.ts new file mode 100644 index 0000000..f73201d --- /dev/null +++ b/e2e/tests/markers.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/region-export.spec.ts b/e2e/tests/region-export.spec.ts new file mode 100644 index 0000000..3f4474c --- /dev/null +++ b/e2e/tests/region-export.spec.ts @@ -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(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..b1210da --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node", "@playwright/test"] + } +} diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index 4b624e1..cecc2dc 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -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() { diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index 690100c..94b01ea 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -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")