From 166baf4be08b0875ed45cea8f99f04b16f0720e4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 16 May 2026 12:55:15 +0200 Subject: [PATCH] battle-viewer e2e: mock user.games.battle ConnectRPC command Phase 28 moved the battle fetch off the REST passthrough onto the signed envelope, so the Playwright spec's `page.route(...)` against the old REST path no longer intercepts anything and the viewer times out waiting for data. Update the spec to: - Build a FlatBuffers `BattleReport` payload in `fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern). - Add a `user.games.battle` case to the ExecuteCommand mock that decodes the FBS `GameBattleRequest`, returns the encoded report when the battle_id matches the seeded one, and surfaces a canonical `not_found` resultCode otherwise. - Drop the obsolete REST route stubs. - Drive the negative-path test with a real UUID that does not match the seeded one, so the gateway-side switch is the source of the 404 (the old `missing-uuid` literal was no longer a valid wire shape for the UUID decoder). Co-Authored-By: Claude Opus 4.7 --- ui/frontend/tests/e2e/battle-viewer.spec.ts | 63 +++++---- ui/frontend/tests/e2e/fixtures/battle-fbs.ts | 128 +++++++++++++++++++ 2 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 ui/frontend/tests/e2e/fixtures/battle-fbs.ts diff --git a/ui/frontend/tests/e2e/battle-viewer.spec.ts b/ui/frontend/tests/e2e/battle-viewer.spec.ts index e410b2d..117833e 100644 --- a/ui/frontend/tests/e2e/battle-viewer.spec.ts +++ b/ui/frontend/tests/e2e/battle-viewer.spec.ts @@ -1,9 +1,9 @@ // 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). +// Mocks the Connect-RPC `user.games.report` (report with battles + +// bombings) and `user.games.battle` (Phase 28 migration; the viewer +// fetches the full BattleReport through the signed envelope instead +// of the old REST passthrough). // Drives three flows: // 1. Reports view → click battle UUID → viewer renders. // 2. Playback controls: play / step back. @@ -18,6 +18,7 @@ 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 { GameBattleRequest } from "../../src/proto/galaxy/fbs/battle"; import { buildOrderResponsePayload, @@ -25,6 +26,10 @@ import { } from "./fixtures/order-fbs"; import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs"; import { buildReportPayload } from "./fixtures/report-fbs"; +import { + buildBattlePayload, + type BattleFixture, +} from "./fixtures/battle-fbs"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; const GAME_ID = "00000000-0000-0000-0000-000000000010"; @@ -33,13 +38,14 @@ 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 = { +const SAMPLE_BATTLE: BattleFixture = { id: BATTLE_ID, planet: 1, planetName: "Earth", races: { "0": RACE_A, "1": RACE_B }, - ships: { - "10": { + ships: [ + { + key: 10, race: "Earthlings", className: "Cruiser", tech: { WEAPONS: 1 }, @@ -49,7 +55,8 @@ const SAMPLE_BATTLE = { loadQuantity: 0, inBattle: true, }, - "20": { + { + key: 20, race: "Bajori", className: "Hawk", tech: { SHIELDS: 1 }, @@ -59,7 +66,7 @@ const SAMPLE_BATTLE = { 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 }, @@ -160,6 +167,23 @@ async function mockGatewayAndBattle(page: Page): Promise { case "user.games.order.get": payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false); break; + case "user.games.battle": { + const fb = GameBattleRequest.getRootAsGameBattleRequest( + new ByteBuffer(req.payloadBytes), + ); + const id = fb.battleId(); + if ( + id !== null && + id.hi() === BigInt("0x1111111111111111") && + id.lo() === BigInt("0x1111111111111111") + ) { + payload = buildBattlePayload(SAMPLE_BATTLE); + } else { + resultCode = "not_found"; + payload = new Uint8Array(); + } + break; + } default: resultCode = "internal_error"; payload = new Uint8Array(); @@ -179,23 +203,6 @@ async function mockGatewayAndBattle(page: Page): Promise { }, ); - 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 { @@ -267,7 +274,9 @@ test.describe("Phase 27 battle viewer", () => { await mockGatewayAndBattle(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`); + await page.goto( + `/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`, + ); await expect(page.getByTestId("battle-not-found")).toBeVisible(); }); diff --git a/ui/frontend/tests/e2e/fixtures/battle-fbs.ts b/ui/frontend/tests/e2e/fixtures/battle-fbs.ts new file mode 100644 index 0000000..1758342 --- /dev/null +++ b/ui/frontend/tests/e2e/fixtures/battle-fbs.ts @@ -0,0 +1,128 @@ +// Phase 28 — FBS BattleReport payload builder used by Battle Viewer +// Playwright specs. Mirrors the `user.games.battle` ConnectRPC +// response that the gateway re-encodes from the engine's JSON. + +import { Builder } from "flatbuffers"; + +import { + BattleActionReport, + BattleReport, + BattleReportGroup, + RaceEntry, + ShipEntry, + TechEntry, + UUID, +} from "../../../src/proto/galaxy/fbs/battle"; + +export interface BattleGroupFixture { + key: number; + race: string; + className: string; + tech?: Record; + num: number; + numLeft: number; + loadType?: string; + loadQuantity?: number; + inBattle?: boolean; +} + +export interface BattleActionFixture { + a: number; + sa: number; + d: number; + sd: number; + x: boolean; +} + +export interface BattleFixture { + id: string; + planet: number; + planetName: string; + races: Record; + ships: BattleGroupFixture[]; + protocol: BattleActionFixture[]; +} + +export function buildBattlePayload(fixture: BattleFixture): Uint8Array { + const builder = new Builder(512); + + const planetNameOff = builder.createString(fixture.planetName); + + const raceOffsets: number[] = []; + for (const [keyStr, value] of Object.entries(fixture.races)) { + const key = Number(keyStr); + const [hi, lo] = uuidToHiLo(value); + RaceEntry.startRaceEntry(builder); + RaceEntry.addKey(builder, BigInt(key)); + RaceEntry.addValue(builder, UUID.createUUID(builder, hi, lo)); + raceOffsets.push(RaceEntry.endRaceEntry(builder)); + } + const racesVec = BattleReport.createRacesVector(builder, raceOffsets); + + const shipOffsets: number[] = []; + for (const ship of fixture.ships) { + const techOffsets: number[] = []; + for (const [tk, tv] of Object.entries(ship.tech ?? {})) { + const tkOff = builder.createString(tk); + TechEntry.startTechEntry(builder); + TechEntry.addKey(builder, tkOff); + TechEntry.addValue(builder, tv); + techOffsets.push(TechEntry.endTechEntry(builder)); + } + const techVec = BattleReportGroup.createTechVector(builder, techOffsets); + const raceStrOff = builder.createString(ship.race); + const classNameOff = builder.createString(ship.className); + const loadTypeOff = builder.createString(ship.loadType ?? ""); + BattleReportGroup.startBattleReportGroup(builder); + BattleReportGroup.addInBattle(builder, ship.inBattle ?? true); + BattleReportGroup.addNumber(builder, BigInt(ship.num)); + BattleReportGroup.addNumberLeft(builder, BigInt(ship.numLeft)); + BattleReportGroup.addLoadQuantity(builder, ship.loadQuantity ?? 0); + BattleReportGroup.addTech(builder, techVec); + BattleReportGroup.addRace(builder, raceStrOff); + BattleReportGroup.addClassName(builder, classNameOff); + BattleReportGroup.addLoadType(builder, loadTypeOff); + const groupOff = BattleReportGroup.endBattleReportGroup(builder); + ShipEntry.startShipEntry(builder); + ShipEntry.addKey(builder, BigInt(ship.key)); + ShipEntry.addValue(builder, groupOff); + shipOffsets.push(ShipEntry.endShipEntry(builder)); + } + const shipsVec = BattleReport.createShipsVector(builder, shipOffsets); + + const protocolOffsets: number[] = []; + for (const action of fixture.protocol) { + BattleActionReport.startBattleActionReport(builder); + BattleActionReport.addAttacker(builder, BigInt(action.a)); + BattleActionReport.addAttackerShipClass(builder, BigInt(action.sa)); + BattleActionReport.addDefender(builder, BigInt(action.d)); + BattleActionReport.addDefenderShipClass(builder, BigInt(action.sd)); + BattleActionReport.addDestroyed(builder, action.x); + protocolOffsets.push(BattleActionReport.endBattleActionReport(builder)); + } + const protocolVec = BattleReport.createProtocolVector( + builder, + protocolOffsets, + ); + + const [idHi, idLo] = uuidToHiLo(fixture.id); + BattleReport.startBattleReport(builder); + BattleReport.addId(builder, UUID.createUUID(builder, idHi, idLo)); + BattleReport.addPlanet(builder, BigInt(fixture.planet)); + BattleReport.addPlanetName(builder, planetNameOff); + BattleReport.addRaces(builder, racesVec); + BattleReport.addShips(builder, shipsVec); + BattleReport.addProtocol(builder, protocolVec); + builder.finish(BattleReport.endBattleReport(builder)); + return builder.asUint8Array(); +} + +function uuidToHiLo(value: string): [bigint, bigint] { + const hex = value.replace(/-/g, "").toLowerCase(); + if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) { + throw new Error(`buildBattlePayload: invalid uuid ${value}`); + } + const hi = BigInt(`0x${hex.slice(0, 16)}`); + const lo = BigInt(`0x${hex.slice(16, 32)}`); + return [hi, lo]; +}