// Phase 27 — Playwright coverage for the Battle Viewer. // // Mocks both the Connect-RPC `user.games.report` (so the report // renders battles + bombings) and the REST forwarder // `/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` (so the // viewer page loads its `BattleReport` without an engine). // Drives three flows: // 1. Reports view → click battle UUID → viewer renders. // 2. Playback controls: play / step back. // 3. Reports view → click bombing marker proxy → row scrolls // (here approximated by clicking the link in Reports — the // map e2e flow is exercised separately by `map-roundtrip`). import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { ByteBuffer } from "flatbuffers"; import { expect, test, type Page } from "@playwright/test"; 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 { buildOrderResponsePayload, buildOrderGetResponsePayload, } from "./fixtures/order-fbs"; import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs"; import { buildReportPayload } from "./fixtures/report-fbs"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; const GAME_ID = "00000000-0000-0000-0000-000000000010"; const BATTLE_ID = "11111111-1111-1111-1111-111111111111"; const SESSION_ID = "device-session-battle"; const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; const SAMPLE_BATTLE = { id: BATTLE_ID, planet: 1, planetName: "Earth", races: { "0": RACE_A, "1": RACE_B }, ships: { "10": { race: "Earthlings", className: "Cruiser", tech: { WEAPONS: 1 }, num: 3, numLeft: 2, loadType: "EMP", loadQuantity: 0, inBattle: true, }, "20": { race: "Bajori", className: "Hawk", tech: { SHIELDS: 1 }, num: 2, numLeft: 0, loadType: "EMP", loadQuantity: 0, inBattle: true, }, }, protocol: [ { a: 0, sa: 10, d: 1, sd: 20, x: false }, { a: 0, sa: 10, d: 1, sd: 20, x: true }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 0, sa: 10, d: 1, sd: 20, x: true }, ], }; async function mockGatewayAndBattle(page: Page): Promise { const game: GameFixture = { gameId: GAME_ID, gameName: "Phase 27 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: 1, }; 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": { GameReportRequest.getRootAsGameReportRequest( new ByteBuffer(req.payloadBytes), ).gameId(new UUID()); payload = buildReportPayload({ turn: 1, mapWidth: 4000, mapHeight: 4000, race: "Earthlings", localPlanets: [ { number: 1, name: "Earth", x: 2000, y: 2000, size: 1000, resources: 5, population: 4000, industry: 3000, capital: 0, material: 0, colonists: 100, freeIndustry: 800, production: "Cruiser", }, ], battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }], localShipClass: [ { name: "Cruiser", drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 2, }, ], otherShipClass: [ { race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75, }, ], }); break; } case "user.games.order": payload = buildOrderResponsePayload(GAME_ID, [], Date.now()); break; case "user.games.order.get": payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false); 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, headers: { "content-type": "application/json" }, body, }); }, ); await page.route( `**/api/v1/user/games/${GAME_ID}/battles/1/${BATTLE_ID}`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(SAMPLE_BATTLE), }); }, ); await page.route( `**/api/v1/user/games/${GAME_ID}/battles/1/missing-uuid`, async (route) => { await route.fulfill({ status: 404 }); }, ); } 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.describe("Phase 27 battle viewer", () => { test("Reports UUID link opens the battle viewer", async ({ page }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "desktop variant covers the link flow", ); await mockGatewayAndBattle(page); await bootSession(page); await page.goto(`/games/${GAME_ID}/report`); await expect(page.getByTestId("active-view-report")).toBeVisible(); const row = page.getByTestId("report-battle-row").first(); await expect(row).toBeVisible(); await row.click(); await expect(page).toHaveURL( new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`), ); await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible(); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); }); test("playback play + step back updates the frame counter", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "desktop variant covers playback controls", ); await mockGatewayAndBattle(page); await bootSession(page); await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); // Step forward once → 1 / 4. await page.getByTestId("battle-control-step-forward").click(); await expect(page.getByTestId("battle-frame-index")).toContainText("1 / 4"); // Step back to 0 / 4. await page.getByTestId("battle-control-step-back").click(); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); }); test("missing battle id surfaces the not-found state", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "desktop variant covers the negative path", ); await mockGatewayAndBattle(page); await bootSession(page); await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`); await expect(page.getByTestId("battle-not-found")).toBeVisible(); }); test("viewer fits the desktop viewport without a vertical scroll", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "desktop-only height-fit check", ); await page.setViewportSize({ width: 1280, height: 720 }); await mockGatewayAndBattle(page); await bootSession(page); await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible(); // Phase 27 refinement: viewer + log fit the viewport; the // internal log scrolls inside its own pane rather than // growing the page. Allow a small tolerance for fractional // pixel rounding around flex math, but reject any // scrollable overflow beyond a couple of pixels. // Phase 27 refinement: viewer + log fit the viewport; the // internal log scrolls inside its own pane rather than // growing the page. Allow a small tolerance for fractional // pixel rounding around flex math. const overflow = await page.evaluate( () => document.documentElement.scrollHeight - window.innerHeight, ); expect(overflow).toBeLessThanOrEqual(4); }); });