// Phase 26 end-to-end coverage for history mode. The spec boots an // authenticated session, mocks the gateway calls the in-game shell // makes (`lobby.my.games.list`, `user.games.report`), pre-seeds a // local order draft, and drives the new turn navigator + history // banner. // // The active view is `/table/planets` rather than `/map`: the Pixi // renderer can monopolise the headless Chromium main thread for // hundreds of ms after a snapshot change, which lets the navigator // click win the race against Svelte's reactive flush and the // `toContainText` poll find the old "turn ?" state for the entire // 5 s polling window. The table view exercises the same `GameReport` // data pipeline and the same banner / sidebar wiring without that // rendering tail, so the assertions stay deterministic. // // Gateway mock design notes: // - `user.games.order.get` always replies with a non-ok status so // `OrderDraftStore.hydrateFromServer` short-circuits into its // `syncStatus = "error"` branch without overwriting the local // cache. This keeps the pre-seeded draft in memory across the // boot path, which is what we need to assert "draft survives a // history round-trip". // - `user.games.report` answers any requested turn with a turn // stamp in the local-planet names so a future diagnostic can // prove the rendered snapshot matches the requested turn. // - `SubscribeEvents` is held open so the revocation watcher does // not bounce the test back to `/login`. 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 { 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-26-history-session"; const GAME_ID = "11111111-2222-3333-4444-555555555555"; const CURRENT_TURN = 5; const SEED_DRAFT = [ { kind: "placeholder" as const, id: "cmd-a", label: "first" }, { kind: "placeholder" as const, id: "cmd-b", label: "second" }, ]; interface MockState { reportRequests: number[]; } async function mockGateway(page: Page): Promise { const state: MockState = { reportRequests: [] }; const baseGame = (): GameFixture => ({ gameId: GAME_ID, gameName: "Phase 26 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: CURRENT_TURN, }); 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 = new Uint8Array(new ArrayBuffer(0)); const errorPayload = (message: string): Uint8Array => { const text = new TextEncoder().encode( JSON.stringify({ code: "internal_error", message }), ); const buf = new ArrayBuffer(text.byteLength); new Uint8Array(buf).set(text); return new Uint8Array(buf); }; switch (req.messageType) { case "lobby.my.games.list": payload = buildMyGamesListPayload([baseGame()]); break; case "user.games.report": { const decoded = GameReportRequest.getRootAsGameReportRequest( new ByteBuffer(req.payloadBytes), ); const turn = decoded.turn(); state.reportRequests.push(turn); const localPlanets = [ { number: 1, name: `Home-${turn}`, x: 1000, y: 1000, }, ]; payload = buildReportPayload({ turn, mapWidth: 4000, mapHeight: 4000, localPlanets, }); break; } case "user.games.order.get": { // Force `hydrateFromServer` into its catch branch so // the seeded local draft survives the boot path. resultCode = "internal_error"; payload = errorPayload("test stub"); break; } default: resultCode = "internal_error"; payload = errorPayload(`unstubbed ${req.messageType}`); } const body = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode, payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body, }); }, ); await page.route( "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", async () => { await new Promise(() => {}); }, ); return state; } async function seedShell(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, commands }) => window.__galaxyDebug!.clearOrderDraft(gameId).then(() => window.__galaxyDebug!.seedOrderDraft(gameId, commands), ), { gameId: GAME_ID, commands: SEED_DRAFT }, ); } test("navigating to a past turn enters history mode and back-to-current restores the draft", async ({ page, isMobile, }) => { const state = await mockGateway(page); await seedShell(page); await page.goto(`/games/${GAME_ID}/table/planets`); await expect(page.getByTestId("turn-navigator-trigger")).toContainText( `turn ${CURRENT_TURN}`, ); // Live mode: banner hidden, order tab reachable. await expect(page.getByTestId("history-banner")).toHaveCount(0); // Order tab is visible. We expect both Sidebar (desktop / tablet) // and BottomTabs (mobile) wirings — the Phase 12 prop pair flips // off together when historyMode goes true. if (isMobile) { await expect(page.getByTestId("bottom-tab-order")).toBeVisible(); } else { await expect(page.getByTestId("sidebar-tab-order")).toBeVisible(); } // Step back one turn with the prev arrow. await page.getByTestId("turn-navigator-prev").click(); await expect(page.getByTestId("turn-navigator-trigger")).toContainText( `turn ${CURRENT_TURN - 1}`, ); await expect(page.getByTestId("history-banner")).toBeVisible(); await expect(page.getByTestId("history-banner")).toContainText( `Viewing turn ${CURRENT_TURN - 1}`, ); // Order tab vanishes from both wirings in history mode. if (isMobile) { await expect(page.getByTestId("bottom-tab-order")).toHaveCount(0); } else { await expect(page.getByTestId("sidebar-tab-order")).toHaveCount(0); } // Open the navigator popover and jump to turn 2 directly. await page.getByTestId("turn-navigator-trigger").click(); const list = page.getByTestId("turn-navigator-list"); await expect(list).toBeVisible(); await expect( list.getByTestId("turn-navigator-item-0"), ).toBeVisible(); await expect( list.getByTestId("turn-navigator-item-5"), ).toBeVisible(); await expect( list.getByTestId("turn-navigator-current-badge"), ).toBeVisible(); await page.getByTestId("turn-navigator-item-2").click(); await expect(page.getByTestId("turn-navigator-trigger")).toContainText( "turn 2", ); await expect(page.getByTestId("history-banner")).toContainText( "Viewing turn 2", ); // Click the banner action; live mode resumes. await page.getByTestId("history-banner-return").click(); await expect(page.getByTestId("history-banner")).toHaveCount(0); await expect(page.getByTestId("turn-navigator-trigger")).toContainText( `turn ${CURRENT_TURN}`, ); // Order tab is back and the seeded draft survives the round-trip. if (isMobile) { await page.getByTestId("bottom-tab-order").click(); } else { await page.getByTestId("sidebar-tab-order").click(); } await expect(page.getByTestId("sidebar-tool-order")).toBeVisible(); const list2 = page.getByTestId("order-list"); await expect(list2).toBeVisible(); for (let i = 0; i < SEED_DRAFT.length; i++) { await expect(page.getByTestId(`order-command-${i}`)).toBeVisible(); } // The mock served every requested turn (5 on boot, 4 via arrow, // 2 via dropdown, 5 again on return). The exact sequence proves // `viewTurn` does not bypass the network for live turns and // historical fetches hit the gateway when no cache row is present. expect(state.reportRequests).toEqual([5, 4, 2, 5]); });