// Phase 11 end-to-end coverage for the live map integration. Boots // an authenticated session through `/__debug/store`, mocks the two // gateway calls the layout makes (`lobby.my.games.list` and // `user.games.report`), navigates to `/games//map`, and // asserts the chrome reflects the live data: turn counter shows the // reported turn, the map view enters its `ready` state with a // non-zero planet count, and a zero-planet response renders the // empty world without errors. import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { expect, test, type Page } from "@playwright/test"; import { ByteBuffer } from "flatbuffers"; import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb"; import { UUID } from "../../src/proto/galaxy/fbs/common"; import { GameReportRequest } from "../../src/proto/galaxy/fbs/report"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { buildMyGamesListPayload, type GameFixture, } from "./fixtures/lobby-fbs"; import { buildReportPayload } from "./fixtures/report-fbs"; const SESSION_ID = "phase-11-map-session"; const GAME_ID = "11111111-2222-3333-4444-555555555555"; interface MockOpts { currentTurn: number; report: Parameters[0]; gameId?: string; } interface MockState { reportRequests: Array<{ gameId: string; turn: number }>; } async function mockGateway(page: Page, opts: MockOpts): Promise { const state: MockState = { reportRequests: [] }; const gameId = opts.gameId ?? GAME_ID; const game: GameFixture = { gameId, gameName: "Phase 11 Game", gameType: "private", status: "running", ownerUserId: "user-1", minPlayers: 2, maxPlayers: 8, enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000), createdAtMs: BigInt(Date.now() - 86_400_000), updatedAtMs: BigInt(Date.now()), currentTurn: opts.currentTurn, }; await page.route( "**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand", async (route) => { const reqText = route.request().postData(); if (reqText === null) { await route.fulfill({ status: 400 }); return; } const req = fromJson( ExecuteCommandRequestSchema, JSON.parse(reqText) as JsonValue, ); let resultCode = "ok"; let payload: Uint8Array; switch (req.messageType) { case "lobby.my.games.list": payload = buildMyGamesListPayload([game]); break; case "user.games.report": { const decoded = GameReportRequest.getRootAsGameReportRequest( new ByteBuffer(req.payloadBytes), ); const idStruct = decoded.gameId(new UUID()); const hi = idStruct?.hi() ?? 0n; const lo = idStruct?.lo() ?? 0n; state.reportRequests.push({ gameId: hiLoToUuid(hi, lo), turn: decoded.turn(), }); payload = buildReportPayload(opts.report); break; } default: resultCode = "internal_error"; payload = new Uint8Array(); } const body = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode, payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body, }); }, ); // Hold the SubscribeEvents stream open indefinitely. The // revocation watcher in `lib/revocation-watcher.ts` treats a clean // end-of-stream as `session_invalidation` and calls // `session.signOut("revoked")`, which would bounce the page back // to `/login`. Playwright aborts pending routes on test teardown, // the watcher's catch path logs the abort and returns without a // sign-out — same convention as `tests/e2e/lobby-flow.spec.ts`. await page.route( "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", async () => { await new Promise(() => {}); }, ); return state; } function hiLoToUuid(hi: bigint, lo: bigint): string { const toHex = (v: bigint): string => v.toString(16).padStart(16, "0"); const full = toHex(hi) + toHex(lo); return [ full.slice(0, 8), full.slice(8, 12), full.slice(12, 16), full.slice(16, 20), full.slice(20, 32), ].join("-"); } async function bootSession(page: Page): Promise { await page.goto("/__debug/store"); await expect(page.getByTestId("debug-store-ready")).toBeVisible(); await page.waitForFunction(() => window.__galaxyDebug?.ready === true); await page.evaluate(() => window.__galaxyDebug!.clearSession()); await page.evaluate( (id) => window.__galaxyDebug!.setDeviceSessionId(id), SESSION_ID, ); } test("map view renders the reported turn and planet count from a live report", async ({ page, }) => { const mocks = await mockGateway(page, { currentTurn: 4, report: { turn: 4, mapWidth: 4000, mapHeight: 4000, localPlanets: [ { number: 1, name: "Home", x: 1000, y: 1000 }, { number: 2, name: "Outpost", x: 1500, y: 1300 }, ], otherPlanets: [ { number: 3, name: "Frontier", x: 2200, y: 2200, owner: "Federation", }, ], uninhabitedPlanets: [{ number: 4, name: "Rock", x: 800, y: 2400 }], }, }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await expect(page.getByTestId("turn-counter")).toContainText("turn 4"); await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute( "data-planet-count", "4", ); expect(mocks.reportRequests.length).toBeGreaterThanOrEqual(1); expect(mocks.reportRequests[0]?.gameId).toBe(GAME_ID); expect(mocks.reportRequests[0]?.turn).toBe(4); }); test("zero-planet game renders the empty world without errors", async ({ page, }) => { await mockGateway(page, { currentTurn: 0, report: { turn: 0, mapWidth: 4000, mapHeight: 4000, }, }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await expect(page.getByTestId("turn-counter")).toContainText("turn 0"); await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute( "data-planet-count", "0", ); await expect(page.getByTestId("map-error")).not.toBeVisible(); await expect(page.getByTestId("map-mount-error")).not.toBeVisible(); }); test("missing-membership game surfaces an error instead of a blank canvas", async ({ page, }) => { // The gateway returns lobby.my.games.list with a different game id // so the layout's gameState lookup misses; the store flips to // `error` and the map view renders the localised error overlay. await mockGateway(page, { currentTurn: 0, gameId: "99999999-aaaa-bbbb-cccc-000000000000", report: { turn: 0 }, }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("map-error")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "error", ); });