// Phase 25 end-to-end coverage for the sync protocol additions on // the order tab: the offline / online flip, the // `turn_already_closed` conflict banner, and the `game.paused` push // frame. Each test boots an authenticated session, mocks the lobby // + report + order routes, drives an order mutation through the // inspector, and asserts the matching banner / sync-status DOM. 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 { UserGamesOrder, UserGamesOrderGet, } from "../../src/proto/galaxy/fbs/order"; import { GameReportRequest } from "../../src/proto/galaxy/fbs/report"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { forgeGatewayEventFrame } from "./fixtures/sign-event"; import { buildMyGamesListPayload, type GameFixture, } from "./fixtures/lobby-fbs"; import { buildReportPayload } from "./fixtures/report-fbs"; import { buildOrderGetResponsePayload, buildOrderResponsePayload, type CommandResultFixture, } from "./fixtures/order-fbs"; const SESSION_ID = "phase-25-order-sync-session"; const GAME_ID = "25252525-2525-2525-2525-252525252525"; const WORLD = 4000; const CENTRE = WORLD / 2; const TURN = 4; type SubmitVerdict = "applied" | "rejected" | "turn_already_closed" | "game_paused"; interface MockOpts { /** Initial server-side order returned by `user.games.order.get`. */ storedOrder?: CommandResultFixture[]; /** How the first `user.games.order` submit replies. */ initialSubmitVerdict: SubmitVerdict; /** * If set, the SubscribeEvents stream emits this frame instead of * holding the connection open. Used by the paused-banner test. */ subscribeFrame?: { eventType: string; payload: Uint8Array }; } interface MockHandle { /** Setter the test uses to flip the verdict mid-run. */ setSubmitVerdict(next: SubmitVerdict): void; /** Read-only counter for assertion. */ get submitCallCount(): number; } async function mockGateway(page: Page, opts: MockOpts): Promise { const game: GameFixture = { gameId: GAME_ID, gameName: "Phase 25 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: TURN, }; let storedOrder = (opts.storedOrder ?? []).slice(); let submitVerdict: SubmitVerdict = opts.initialSubmitVerdict; let submitCalls = 0; 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; let bodyOverride: string | null = null; 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: TURN, mapWidth: WORLD, mapHeight: WORLD, localPlanets: [ { number: 17, name: "Earth", x: CENTRE, y: CENTRE, size: 1000, resources: 10, capital: 0, material: 0, population: 850, colonists: 25, industry: 700, production: "drive", freeIndustry: 175, }, ], }); break; } case "user.games.order": { submitCalls += 1; const decoded = UserGamesOrder.getRootAsUserGamesOrder( new ByteBuffer(req.payloadBytes), ); const length = decoded.commandsLength(); const fixtures: CommandResultFixture[] = []; for (let i = 0; i < length; i++) { const item = decoded.commands(i); if (item === null) continue; const cmdId = item.cmdId() ?? ""; const inner = new (await import( "../../src/proto/galaxy/fbs/order" )).CommandPlanetRename(); item.payload(inner); const submittedName = inner.name() ?? ""; const applied = submitVerdict === "applied"; fixtures.push({ kind: "planetRename", cmdId, planetNumber: Number(inner.number()), name: submittedName, applied, errorCode: applied ? null : 1, }); } if (submitVerdict === "turn_already_closed") { resultCode = "turn_already_closed"; bodyOverride = JSON.stringify({ code: "turn_already_closed", message: "turn closed before submit", }); } else if (submitVerdict === "game_paused") { resultCode = "game_paused"; bodyOverride = JSON.stringify({ code: "game_paused", message: "game is paused", }); } if (submitVerdict === "applied") { storedOrder = fixtures; } payload = bodyOverride !== null ? new TextEncoder().encode(bodyOverride) : buildOrderResponsePayload(GAME_ID, fixtures, Date.now()); break; } case "user.games.order.get": { UserGamesOrderGet.getRootAsUserGamesOrderGet( new ByteBuffer(req.payloadBytes), ); payload = buildOrderGetResponsePayload( GAME_ID, storedOrder, Date.now(), storedOrder.length > 0, ); 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, }); }, ); let subscribeServed = false; await page.route( "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", async (route) => { if (opts.subscribeFrame !== undefined && !subscribeServed) { subscribeServed = true; const frame = await forgeGatewayEventFrame({ eventType: opts.subscribeFrame.eventType, eventId: "evt-phase25-1", timestampMs: BigInt(Date.now()), requestId: "req-phase25-1", traceId: "trace-phase25-1", payloadBytes: opts.subscribeFrame.payload, }); await route.fulfill({ status: 200, contentType: "application/connect+json", body: Buffer.from(frame), }); return; } await new Promise(() => {}); }, ); return { setSubmitVerdict(next) { submitVerdict = next; }, get submitCallCount() { return submitCalls; }, }; } 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, ); await page.evaluate( (gameId) => window.__galaxyDebug!.clearOrderDraft(gameId), GAME_ID, ); } async function clickPlanetCentre(page: Page): Promise { const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) throw new Error("canvas has no bounding box"); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); } async function startRename(page: Page, newName: string): Promise { await clickPlanetCentre(page); const sidebar = page.getByTestId("sidebar-tool-inspector"); await sidebar.getByTestId("inspector-planet-rename-action").click(); const input = sidebar.getByTestId("inspector-planet-rename-input"); await input.fill(newName); await sidebar.getByTestId("inspector-planet-rename-confirm").click(); } test("turn_already_closed surfaces the conflict banner on the order tab", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 25 spec covers desktop layout; mobile inherits the same store", ); await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await startRename(page, "Conflict-Earth"); await page.getByTestId("sidebar-tab-order").click(); const orderTool = page.getByTestId("sidebar-tool-order"); await expect(orderTool.getByTestId("order-conflict-banner")).toBeVisible({ timeout: 5_000, }); await expect(orderTool.getByTestId("order-conflict-banner")).toContainText( "Edit and resubmit", ); await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( "conflict", ); await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( "data-sync-status", "conflict", ); }); test("game.paused push frame surfaces the paused banner", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 25 spec covers desktop layout; mobile inherits the same store", ); const payload = new TextEncoder().encode( JSON.stringify({ game_id: GAME_ID, turn: TURN, reason: "generation_failed", }), ); await mockGateway(page, { initialSubmitVerdict: "applied", subscribeFrame: { eventType: "game.paused", payload }, }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await page.getByTestId("sidebar-tab-order").click(); const orderTool = page.getByTestId("sidebar-tool-order"); await expect(orderTool.getByTestId("order-paused-banner")).toBeVisible({ timeout: 5_000, }); await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( "data-sync-status", "paused", ); });