Add standing Playwright e2e suite; fix two real bugs it caught
The whole point of driving a real browser instead of curling api/frontend separately: none of this session's prior "live verification" ever exercised the same-origin routing production relies on (Caddy: /ws*+/api/* -> api, else -> frontend), so a real browser's relative fetch()/WebSocket calls were never actually proven to resolve. e2e/proxy.ts mirrors that routing (no caddy binary available locally); global-setup.ts/global-teardown.ts orchestrate throwaway infra + seeded data + the api/frontend/proxy processes end to end. Getting the suite green surfaced two genuine bugs invisible to unit tests: - index.pug loaded map.js via two <script type="module"> tags (one moved to <head> to fix load-order, the original left in place by mistake), causing Alpine's x-init="init()" to run twice and Leaflet to throw "Map container is already initialized" on the second call. - map.js's exportRegion() passed the Alpine-reactive `regionBounds` object straight into worker.postMessage(); Alpine wraps assigned state in Proxies, which the structured clone algorithm can't clone, so every export silently failed. Fixed by spreading into a plain object first. Covers the two flows flagged all session as verified only at the unit/curl level: the marker click-to-place/edit popup (including that a marker created while linked shows up in a second browser context with the same session, proving server-side sync) and the region-select drag + glTF export (including a real triggered file download).
This commit is contained in:
@@ -101,6 +101,33 @@ bun test
|
|||||||
Each test file creates and tears down its own server row (random token per run) so runs never
|
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`.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
.e2e-state.json
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@mcmapper/e2e",
|
||||||
|
"dependencies": {
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
|
||||||
|
|
||||||
|
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Shared constants between global-setup.ts, global-teardown.ts, playwright.config.ts, and the
|
||||||
|
// proxy process — kept in one place deliberately (see region-select.js's MAX_EXPORT_CHUNKS
|
||||||
|
// comment for why hand-synced duplicate constants across files are worth avoiding when a single
|
||||||
|
// source is this easy).
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export const ROOT = join(import.meta.dirname, "..");
|
||||||
|
export const API_DIR = join(ROOT, "api");
|
||||||
|
export const FRONTEND_DIR = join(ROOT, "frontend");
|
||||||
|
|
||||||
|
// Distinct container names/ports from the README's `bun test` throwaway infra (mcmapper-test-*
|
||||||
|
// on 15432/16379/19000) so an e2e run and a `bun test` run can happen at the same time without
|
||||||
|
// colliding.
|
||||||
|
export const PG_CONTAINER = "mcmapper-e2e-pg";
|
||||||
|
export const REDIS_CONTAINER = "mcmapper-e2e-redis";
|
||||||
|
export const MINIO_CONTAINER = "mcmapper-e2e-minio";
|
||||||
|
|
||||||
|
export const PG_PORT = 15433;
|
||||||
|
export const REDIS_PORT = 16380;
|
||||||
|
export const MINIO_PORT = 19002;
|
||||||
|
|
||||||
|
export const API_PORT = 13010;
|
||||||
|
export const FRONTEND_PORT = 13011;
|
||||||
|
export const PROXY_PORT = 14010;
|
||||||
|
|
||||||
|
export const DATABASE_URL = `postgres://mcmapper:mcmapper@localhost:${PG_PORT}/mcmapper`;
|
||||||
|
export const REDIS_URL = `redis://localhost:${REDIS_PORT}`;
|
||||||
|
export const MINIO_ACCESS_KEY = "mcmapper";
|
||||||
|
export const MINIO_SECRET_KEY = "mcmapper-e2e-only";
|
||||||
|
|
||||||
|
export const PROXY_ORIGIN = `http://localhost:${PROXY_PORT}`;
|
||||||
|
|
||||||
|
export const STATE_FILE = join(import.meta.dirname, ".e2e-state.json");
|
||||||
|
|
||||||
|
export type E2eState = {
|
||||||
|
pids: number[];
|
||||||
|
containers: string[];
|
||||||
|
baseURL: string;
|
||||||
|
serverId: string;
|
||||||
|
serverToken: string;
|
||||||
|
sessionToken: string;
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read by spec files — by the time tests run, global-setup.ts has already written this.
|
||||||
|
export function readE2eState(): E2eState {
|
||||||
|
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Orchestrates everything a real browser session needs, mirroring production's Caddy-routed
|
||||||
|
// single-origin setup (see proxy.ts) since that routing contract was never actually exercised by
|
||||||
|
// any of this project's prior manual verification — see README's e2e section.
|
||||||
|
//
|
||||||
|
// Uses globalSetup/globalTeardown (manual orchestration) rather than Playwright's built-in
|
||||||
|
// `webServer` option: the proxy has no independent readiness signal until api+frontend are
|
||||||
|
// already listening, which `webServer`'s single command-per-entry model doesn't fit well.
|
||||||
|
//
|
||||||
|
// No `caddy` binary is available in this dev environment, hence the hand-rolled proxy.ts instead
|
||||||
|
// of running real Caddy.
|
||||||
|
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import {
|
||||||
|
API_DIR,
|
||||||
|
API_PORT,
|
||||||
|
DATABASE_URL,
|
||||||
|
FRONTEND_DIR,
|
||||||
|
FRONTEND_PORT,
|
||||||
|
MINIO_ACCESS_KEY,
|
||||||
|
MINIO_CONTAINER,
|
||||||
|
MINIO_PORT,
|
||||||
|
MINIO_SECRET_KEY,
|
||||||
|
PG_CONTAINER,
|
||||||
|
PG_PORT,
|
||||||
|
PROXY_ORIGIN,
|
||||||
|
PROXY_PORT,
|
||||||
|
REDIS_CONTAINER,
|
||||||
|
REDIS_PORT,
|
||||||
|
REDIS_URL,
|
||||||
|
ROOT,
|
||||||
|
STATE_FILE,
|
||||||
|
} from "./config";
|
||||||
|
|
||||||
|
function tryRemoveContainer(name: string) {
|
||||||
|
try {
|
||||||
|
execSync(`docker rm -f ${name}`, { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// fine — container didn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(check: () => Promise<boolean>, label: string, timeoutMs = 45000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (await check().catch(() => false)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for ${label} to become ready`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpOk(url: string) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children: ChildProcess[] = [];
|
||||||
|
|
||||||
|
// Windows/Bun sometimes double-binds a port across process restarts (see MCMapper's project
|
||||||
|
// memory) — tracking every spawned PID explicitly and killing them all in global-teardown.ts
|
||||||
|
// avoids that class of zombie-listener bug for these throwaway dev-server instances.
|
||||||
|
function spawnTracked(label: string, cmd: string, args: string[], cwd: string, env: Record<string, string>) {
|
||||||
|
const child = spawn(cmd, args, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
// Posix: makes the child its own process-group leader so global-teardown.ts can kill
|
||||||
|
// `-pid` (the whole group, including any grandchildren) instead of just this one process.
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
});
|
||||||
|
child.stdout?.on("data", (d) => process.stdout.write(`[${label}] ${d}`));
|
||||||
|
child.stderr?.on("data", (d) => process.stderr.write(`[${label}] ${d}`));
|
||||||
|
children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSolidSection(blockIdMeta: number): string {
|
||||||
|
const bytes = new Uint8Array(4096 * 2);
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
for (let i = 0; i < 4096; i++) view.setUint16(i * 2, blockIdMeta, true);
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
tryRemoveContainer(PG_CONTAINER);
|
||||||
|
tryRemoveContainer(REDIS_CONTAINER);
|
||||||
|
tryRemoveContainer(MINIO_CONTAINER);
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`docker run -d --name ${PG_CONTAINER} -e POSTGRES_USER=mcmapper -e POSTGRES_PASSWORD=mcmapper -e POSTGRES_DB=mcmapper -p ${PG_PORT}:5432 postgres:17-alpine`,
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
execSync(`docker run -d --name ${REDIS_CONTAINER} -p ${REDIS_PORT}:6379 redis:7-alpine`, { stdio: "inherit" });
|
||||||
|
execSync(
|
||||||
|
`docker run -d --name ${MINIO_CONTAINER} -e MINIO_ROOT_USER=${MINIO_ACCESS_KEY} -e MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} -p ${MINIO_PORT}:9000 minio/minio:latest server /data`,
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const sql = postgres(DATABASE_URL);
|
||||||
|
await waitFor(async () => {
|
||||||
|
await sql`select 1`;
|
||||||
|
return true;
|
||||||
|
}, "postgres");
|
||||||
|
|
||||||
|
execSync("bun run migrate", { cwd: API_DIR, stdio: "inherit", env: { ...process.env, DATABASE_URL } });
|
||||||
|
|
||||||
|
// Seed a server + a linked account/session (so markers.spec.ts can exercise the server-synced
|
||||||
|
// marker path, not just the anonymous/localStorage one) + a 5x5-chunk solid terrain footprint
|
||||||
|
// centered on the world origin, where the map's default view (`setView([0,0],0)`) is centered
|
||||||
|
// — generous enough that a drag anywhere near the visible center reliably selects real,
|
||||||
|
// meshable terrain without the test needing to compute exact Leaflet pixel<->world math.
|
||||||
|
const serverToken = `e2e-${crypto.randomUUID()}`;
|
||||||
|
const [server] = await sql<{ id: string }[]>`
|
||||||
|
insert into servers (name, token) values ('e2e-server', ${serverToken}) returning id
|
||||||
|
`;
|
||||||
|
const [account] = await sql<{ id: string }[]>`
|
||||||
|
insert into accounts (mc_uuid, username, auth_mode)
|
||||||
|
values (${"e2e-" + crypto.randomUUID()}, 'e2e-player', 'online')
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
const sessionToken = `e2e-session-${crypto.randomUUID()}`;
|
||||||
|
await sql`insert into sessions (token, account_id) values (${sessionToken}, ${account!.id})`;
|
||||||
|
|
||||||
|
const blocks = encodeSolidSection((2 << 4) | 0); // grass
|
||||||
|
for (let x = -2; x <= 2; x++) {
|
||||||
|
for (let z = -2; z <= 2; z++) {
|
||||||
|
await sql`
|
||||||
|
insert into chunk_sections (server_id, dimension, x, z, section_y, blocks)
|
||||||
|
values (${server!.id}, 0, ${x}, ${z}, 3, ${blocks})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
|
|
||||||
|
spawnTracked("api", "bun", ["run", "src/index.ts"], API_DIR, {
|
||||||
|
PORT: String(API_PORT),
|
||||||
|
DATABASE_URL,
|
||||||
|
REDIS_URL,
|
||||||
|
MINIO_ENDPOINT: "localhost",
|
||||||
|
MINIO_PORT: String(MINIO_PORT),
|
||||||
|
MINIO_USE_SSL: "false",
|
||||||
|
MINIO_ACCESS_KEY,
|
||||||
|
MINIO_SECRET_KEY,
|
||||||
|
});
|
||||||
|
await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api");
|
||||||
|
|
||||||
|
spawnTracked("frontend", "bun", ["run", "src/index.ts"], FRONTEND_DIR, {
|
||||||
|
PORT: String(FRONTEND_PORT),
|
||||||
|
API_URL: `http://localhost:${API_PORT}`,
|
||||||
|
});
|
||||||
|
await waitFor(() => httpOk(`http://localhost:${FRONTEND_PORT}/health`), "frontend");
|
||||||
|
|
||||||
|
spawnTracked("proxy", "bun", ["run", "proxy.ts"], ROOT + "/e2e", {
|
||||||
|
E2E_PROXY_PORT: String(PROXY_PORT),
|
||||||
|
E2E_API_ORIGIN: `http://localhost:${API_PORT}`,
|
||||||
|
E2E_FRONTEND_ORIGIN: `http://localhost:${FRONTEND_PORT}`,
|
||||||
|
});
|
||||||
|
await waitFor(() => httpOk(`${PROXY_ORIGIN}/__proxy_health`), "proxy");
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
STATE_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
pids: children.map((c) => c.pid).filter((pid): pid is number => pid !== undefined),
|
||||||
|
containers: [PG_CONTAINER, REDIS_CONTAINER, MINIO_CONTAINER],
|
||||||
|
baseURL: PROXY_ORIGIN,
|
||||||
|
serverId: server!.id,
|
||||||
|
serverToken,
|
||||||
|
sessionToken,
|
||||||
|
accountId: account!.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[global-setup] ready:", PROXY_ORIGIN);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Kills every process global-setup.ts spawned and removes the throwaway containers it started.
|
||||||
|
// Reads .e2e-state.json rather than relying on in-memory state from global-setup.ts, so teardown
|
||||||
|
// is correct even if it's ever invoked independently — see global-setup.ts's doc comment on the
|
||||||
|
// zombie-listener gotcha this is specifically guarding against.
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { STATE_FILE, type E2eState } from "./config";
|
||||||
|
|
||||||
|
function killPid(pid: number) {
|
||||||
|
try {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// shell:true spawned bun via cmd.exe — killing just the parent PID leaves the real bun
|
||||||
|
// process running; /T kills the whole tree.
|
||||||
|
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
|
||||||
|
} else {
|
||||||
|
process.kill(-pid, "SIGKILL"); // negative pid: whole process group (spawned with shell)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// already dead — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
if (!existsSync(STATE_FILE)) return;
|
||||||
|
|
||||||
|
const state = JSON.parse(readFileSync(STATE_FILE, "utf-8")) as E2eState;
|
||||||
|
|
||||||
|
for (const pid of state.pids) killPid(pid);
|
||||||
|
|
||||||
|
for (const container of state.containers) {
|
||||||
|
try {
|
||||||
|
execSync(`docker rm -f ${container}`, { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(STATE_FILE, { force: true });
|
||||||
|
console.log("[global-teardown] cleaned up");
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcmapper/e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
|
"@types/node": "^26.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"postgres": "^3.4.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
import { PROXY_ORIGIN } from "./config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
fullyParallel: false, // all tests share one seeded server/dataset — keep it simple, not racy
|
||||||
|
retries: 0,
|
||||||
|
reporter: "list",
|
||||||
|
globalSetup: "./global-setup.ts",
|
||||||
|
globalTeardown: "./global-teardown.ts",
|
||||||
|
timeout: 30000,
|
||||||
|
use: {
|
||||||
|
baseURL: PROXY_ORIGIN,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Minimal reverse proxy mirroring the production Caddyfile's routing rules, used only for
|
||||||
|
// Playwright e2e runs — no `caddy` binary is available in this dev environment (see
|
||||||
|
// global-setup.ts). Routing matches Caddyfile exactly: /ws* and /api/* -> api, everything else
|
||||||
|
// -> frontend. This matters because the frontend's browser JS (map.js) makes same-origin
|
||||||
|
// relative calls (`fetch("/api/...")`, `new WebSocket(".../ws/chat/...")`) that only resolve
|
||||||
|
// correctly behind a single proxied origin like this one — see the README's e2e section for why
|
||||||
|
// that's the whole point of testing through this proxy instead of hitting api/frontend directly.
|
||||||
|
const PORT = Number(process.env.E2E_PROXY_PORT);
|
||||||
|
const API_ORIGIN = process.env.E2E_API_ORIGIN!; // e.g. http://localhost:13010
|
||||||
|
const FRONTEND_ORIGIN = process.env.E2E_FRONTEND_ORIGIN!; // e.g. http://localhost:13011
|
||||||
|
|
||||||
|
function targetOrigin(pathname: string): string {
|
||||||
|
if (pathname.startsWith("/ws") || pathname.startsWith("/api/")) return API_ORIGIN;
|
||||||
|
return FRONTEND_ORIGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsData = {
|
||||||
|
targetUrl: string;
|
||||||
|
upstream?: WebSocket;
|
||||||
|
queue: (string | ArrayBuffer)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
Bun.serve<WsData, {}>({
|
||||||
|
port: PORT,
|
||||||
|
async fetch(req, server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Synthetic, non-forwarded endpoint so global-setup can wait for *this* process specifically
|
||||||
|
// (the one tests actually talk to) instead of inferring proxy readiness from api/frontend's
|
||||||
|
// own health checks, which would leave a chicken-and-egg gap.
|
||||||
|
if (url.pathname === "/__proxy_health") {
|
||||||
|
return new Response("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = targetOrigin(url.pathname);
|
||||||
|
|
||||||
|
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||||
|
const targetUrl = origin.replace(/^http/, "ws") + url.pathname + url.search;
|
||||||
|
const upgraded = server.upgrade(req, { data: { targetUrl, queue: [] } });
|
||||||
|
return upgraded ? undefined : new Response("upgrade failed", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("content-length");
|
||||||
|
|
||||||
|
const res = await fetch(origin + url.pathname + url.search, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body: ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resHeaders = new Headers(res.headers);
|
||||||
|
resHeaders.delete("content-encoding"); // fetch() already decoded the body
|
||||||
|
return new Response(res.body, { status: res.status, headers: resHeaders });
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
open(ws) {
|
||||||
|
const upstream = new WebSocket(ws.data.targetUrl);
|
||||||
|
ws.data.upstream = upstream;
|
||||||
|
upstream.onopen = () => {
|
||||||
|
for (const msg of ws.data.queue) upstream.send(msg as any);
|
||||||
|
ws.data.queue.length = 0;
|
||||||
|
};
|
||||||
|
upstream.onmessage = (ev) => ws.send(ev.data);
|
||||||
|
upstream.onclose = () => ws.close();
|
||||||
|
},
|
||||||
|
message(ws, message) {
|
||||||
|
const upstream = ws.data.upstream;
|
||||||
|
if (upstream && upstream.readyState === WebSocket.OPEN) upstream.send(message as any);
|
||||||
|
else ws.data.queue.push(message);
|
||||||
|
},
|
||||||
|
close(ws) {
|
||||||
|
ws.data.upstream?.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[e2e-proxy] listening on :${PORT} -> api=${API_ORIGIN} frontend=${FRONTEND_ORIGIN}`);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Covers the click-to-place/edit marker popup flow (Leaflet L.popup() + Alpine's $refs
|
||||||
|
// DOM-node-reuse trick in map.js's init()/openMarkerForm) — flagged throughout MCMapper's
|
||||||
|
// development as verified only at the unit-test/manual-curl level, never actually clicked
|
||||||
|
// through in a real browser. See README's e2e section.
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { readE2eState } from "../config";
|
||||||
|
|
||||||
|
const state = readE2eState();
|
||||||
|
const SESSION_STORAGE_KEY = "mcmapper_session";
|
||||||
|
|
||||||
|
async function waitForMapReady(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
|
||||||
|
// Leaflet adds this class to the same #map div passed to L.map() — waiting for it means the
|
||||||
|
// map/tile layer/view are fully initialized before we try to click on it.
|
||||||
|
await page.locator("#map.leaflet-container").waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function placeMarkerAt(page: Page, offset: { x: number; y: number }) {
|
||||||
|
await page.getByTestId("place-marker-toggle").click();
|
||||||
|
const map = page.locator("#map");
|
||||||
|
const box = await map.boundingBox();
|
||||||
|
if (!box) throw new Error("#map has no bounding box");
|
||||||
|
await map.click({ position: offset });
|
||||||
|
// Not `.leaflet-popup` — Leaflet doesn't remove a previously-closed popup's container from the
|
||||||
|
// DOM, only the shared markerFormEl content node (moved between popups, see map.js's
|
||||||
|
// openMarkerForm doc comment) is guaranteed unique and reflects the currently-open popup.
|
||||||
|
await expect(page.getByTestId("marker-form")).toBeVisible();
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("marker popup — anonymous (localStorage)", () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test("clicking the map opens a popup pre-filled with defaults", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await placeMarkerAt(page, { x: 400, y: 300 });
|
||||||
|
|
||||||
|
await expect(page.getByTestId("marker-y")).toHaveValue("60");
|
||||||
|
await expect(page.getByTestId("marker-x")).not.toHaveValue("");
|
||||||
|
await expect(page.getByTestId("marker-z")).not.toHaveValue("");
|
||||||
|
|
||||||
|
const color = await page.getByTestId("marker-color-input").inputValue();
|
||||||
|
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creating a marker adds it to the list and survives a reload", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await placeMarkerAt(page, { x: 420, y: 280 });
|
||||||
|
|
||||||
|
await page.getByTestId("marker-name-input").fill("Test Marker");
|
||||||
|
await page.getByTestId("marker-save").click();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("place-marker-toggle")).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Test Marker"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing a marker via the popup updates its name", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await placeMarkerAt(page, { x: 440, y: 260 });
|
||||||
|
await page.getByTestId("marker-name-input").fill("Before Edit");
|
||||||
|
await page.getByTestId("marker-save").click();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]').getByTestId("marker-edit").click();
|
||||||
|
await expect(page.getByTestId("marker-form")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("marker-name-input")).toHaveValue("Before Edit");
|
||||||
|
|
||||||
|
await page.getByTestId("marker-name-input").fill("After Edit");
|
||||||
|
await page.getByTestId("marker-save").click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="After Edit"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="Before Edit"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting a marker removes it from the list", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await placeMarkerAt(page, { x: 460, y: 240 });
|
||||||
|
await page.getByTestId("marker-name-input").fill("To Delete");
|
||||||
|
await page.getByTestId("marker-save").click();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]').getByTestId("marker-delete").click();
|
||||||
|
await expect(page.locator('[data-testid="marker-row"][data-marker-name="To Delete"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("marker popup — linked account (server-synced across devices)", () => {
|
||||||
|
async function injectSession(page: Page) {
|
||||||
|
await page.addInitScript(
|
||||||
|
([key, token]) => localStorage.setItem(key, token),
|
||||||
|
[SESSION_STORAGE_KEY, state.sessionToken],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a marker created while linked is visible in a separate browser context with the same session", async ({ browser }) => {
|
||||||
|
const contextA = await browser.newContext();
|
||||||
|
const pageA = await contextA.newPage();
|
||||||
|
await injectSession(pageA);
|
||||||
|
await waitForMapReady(pageA);
|
||||||
|
await expect(pageA.getByTestId("account-linked")).toBeVisible();
|
||||||
|
|
||||||
|
await placeMarkerAt(pageA, { x: 400, y: 300 });
|
||||||
|
const markerName = `Synced Marker ${Date.now()}`;
|
||||||
|
await pageA.getByTestId("marker-name-input").fill(markerName);
|
||||||
|
await pageA.getByTestId("marker-save").click();
|
||||||
|
await expect(pageA.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
|
||||||
|
await contextA.close();
|
||||||
|
|
||||||
|
// A fresh context has its own localStorage — the only way this marker can show up here is
|
||||||
|
// if it was actually persisted server-side (accounts.markers table), proving the
|
||||||
|
// cross-device sync the user asked for, not just localStorage.
|
||||||
|
const contextB = await browser.newContext();
|
||||||
|
const pageB = await contextB.newPage();
|
||||||
|
await injectSession(pageB);
|
||||||
|
await waitForMapReady(pageB);
|
||||||
|
await expect(pageB.getByTestId("account-linked")).toBeVisible();
|
||||||
|
await expect(pageB.locator(`[data-testid="marker-row"][data-marker-name="${markerName}"]`)).toBeVisible();
|
||||||
|
await contextB.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Covers the region-select drag tool (mousedown/mousemove/mouseup handlers wired in map.js's
|
||||||
|
// startRegionSelect/updateRegionPreview/finishRegionSelect) and the client-side glTF export
|
||||||
|
// pipeline (export-worker.js) — the second UI surface flagged throughout MCMapper's Phase 5 work
|
||||||
|
// as verified only via a headless pipeline run against seeded data, never an actual browser
|
||||||
|
// drag gesture. See README's e2e section.
|
||||||
|
//
|
||||||
|
// The seeded dataset (see global-setup.ts) fills a 5x5-chunk solid terrain footprint centered on
|
||||||
|
// the world origin — where the map's default view is centered — so a drag anywhere near the
|
||||||
|
// visible center reliably selects real, meshable terrain without computing exact Leaflet
|
||||||
|
// pixel<->world math.
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function waitForMapReady(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("region-toggle")).toBeVisible();
|
||||||
|
await page.locator("#map.leaflet-container").waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragOnMap(page: Page, fromFrac: number, toFrac: number) {
|
||||||
|
const box = await page.locator("#map").boundingBox();
|
||||||
|
if (!box) throw new Error("#map has no bounding box");
|
||||||
|
const start = { x: box.x + box.width * fromFrac, y: box.y + box.height * fromFrac };
|
||||||
|
const end = { x: box.x + box.width * toFrac, y: box.y + box.height * toFrac };
|
||||||
|
await page.mouse.move(start.x, start.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move((start.x + end.x) / 2, (start.y + end.y) / 2, { steps: 5 });
|
||||||
|
await page.mouse.move(end.x, end.y, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dragging on the map selects a region and reports the chunk count", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await page.getByTestId("region-toggle").click();
|
||||||
|
|
||||||
|
await dragOnMap(page, 0.4, 0.6);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||||
|
await expect(page.getByTestId("region-export")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exporting a selected region downloads a non-empty .glb file", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
await page.getByTestId("region-toggle").click();
|
||||||
|
await dragOnMap(page, 0.35, 0.65);
|
||||||
|
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByTestId("region-export").click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toMatch(/\.glb$/);
|
||||||
|
const path = await download.path();
|
||||||
|
expect(path).not.toBeNull();
|
||||||
|
const { statSync } = await import("node:fs");
|
||||||
|
const size = statSync(path!).size;
|
||||||
|
// A well-formed GLB has at minimum a 12-byte header + JSON chunk header — real seeded terrain
|
||||||
|
// should produce something far larger (actual mesh geometry), so this also catches an
|
||||||
|
// accidentally-empty export, not just a missing file.
|
||||||
|
expect(size).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an oversized selection is rejected with a clear message instead of exporting", async ({ page }) => {
|
||||||
|
await waitForMapReady(page);
|
||||||
|
|
||||||
|
// Zoom out to the map's minimum (minZoom: -4, see map.js's init()) so many more chunks fit in
|
||||||
|
// the same viewport, making it easy to drag past the 64-chunk cap without needing to compute
|
||||||
|
// exact pixel<->world math. Leaflet's zoom-out control gains `leaflet-disabled` once at
|
||||||
|
// minZoom — waiting for that (rather than a fixed click count) avoids racing the zoom
|
||||||
|
// animation, which silently drops clicks fired faster than it can keep up.
|
||||||
|
const zoomOut = page.locator(".leaflet-control-zoom-out");
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (await zoomOut.evaluate((el) => el.classList.contains("leaflet-disabled"))) break;
|
||||||
|
await zoomOut.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByTestId("region-toggle").click();
|
||||||
|
// Not 0.02 — that lands the mousedown on Leaflet's own top-left zoom control, which stops
|
||||||
|
// event propagation before it reaches the map's mousedown listener (startRegionSelect never
|
||||||
|
// fires). Starting past that corner still covers a huge area at minZoom.
|
||||||
|
await dragOnMap(page, 0.15, 0.95);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("region-status")).toHaveText(/too large/);
|
||||||
|
// x-show hides via CSS rather than removing the node, so assert visibility, not DOM count.
|
||||||
|
await expect(page.getByTestId("region-export")).not.toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node", "@playwright/test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,7 +290,12 @@ function mapmapper() {
|
|||||||
this.regionStatus = err.message ?? "export_failed";
|
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() {
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user