a134c47152
Gate a new /api/admin/* route set (register/list/update/delete servers) behind a single shared MCMAPPER_ADMIN_TOKEN header, and add a /admin frontend page (Alpine) to unlock, register new servers, and edit authMode/anonymousChatAllowed/waypointFormat per server — these columns already existed but were only editable via direct DB edit until now. Written test-first per the project's TDD workflow: admin.ts's domain logic, the index.ts route wiring, and a new e2e/tests/admin.spec.ts covering the token gate and register/edit/delete round trip through the real browser UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
// 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 {
|
|
ADMIN_TOKEN,
|
|
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,
|
|
MCMAPPER_ADMIN_TOKEN: ADMIN_TOKEN,
|
|
});
|
|
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,
|
|
adminToken: ADMIN_TOKEN,
|
|
}),
|
|
);
|
|
|
|
console.log("[global-setup] ready:", PROXY_ORIGIN);
|
|
}
|