// Phase 8 lobby end-to-end coverage. The gateway is mocked through // `page.route(...)` like in the Phase 7 spec; this spec dispatches by // `messageType` so each lobby command can return its own forged // FlatBuffers payload. The flows under test: // // 1) Land on /lobby with empty lists; create a private game; verify // the new game appears in My Games after the redirect. // 2) Submit an application to a public game; verify the application // shows up in My Applications. // 3) Accept an invitation; verify the invite card disappears. 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 { GameCreateRequest } from "../../src/proto/galaxy/fbs/lobby"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { buildAccountResponsePayload, buildApplicationSubmitResponsePayload, buildGameCreateResponsePayload, buildInviteRedeemResponsePayload, buildMyApplicationsListPayload, buildMyGamesListPayload, buildMyInvitesListPayload, buildPublicGamesListPayload, type ApplicationFixture, type GameFixture, type InviteFixture, } from "./fixtures/lobby-fbs"; interface LobbyState { myGames: GameFixture[]; publicGames: GameFixture[]; invitations: InviteFixture[]; applications: ApplicationFixture[]; } interface LobbyMocks { state: LobbyState; pendingSubscribes: Array<() => void>; createGameCalls: GameFixture[]; applicationSubmitCalls: Array<{ gameId: string; raceName: string }>; inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>; } async function mockGateway(page: Page, initial: Partial = {}): Promise { const mocks: LobbyMocks = { state: { myGames: initial.myGames ?? [], publicGames: initial.publicGames ?? [], invitations: initial.invitations ?? [], applications: initial.applications ?? [], }, pendingSubscribes: [], createGameCalls: [], applicationSubmitCalls: [], inviteRedeemCalls: [], }; await page.route("**/api/v1/public/auth/send-email-code", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ challenge_id: "ch-test-1" }), }); }); await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ device_session_id: "dev-test-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 "user.account.get": payload = buildAccountResponsePayload({ userId: "user-1", email: "pilot@example.com", userName: "pilot", displayName: "Pilot", }); break; case "lobby.my.games.list": payload = buildMyGamesListPayload(mocks.state.myGames); break; case "lobby.public.games.list": payload = buildPublicGamesListPayload(mocks.state.publicGames); break; case "lobby.my.invites.list": payload = buildMyInvitesListPayload(mocks.state.invitations); break; case "lobby.my.applications.list": payload = buildMyApplicationsListPayload(mocks.state.applications); break; case "lobby.game.create": { const decoded = GameCreateRequest.getRootAsGameCreateRequest( new ByteBuffer(req.payloadBytes), ); const created: GameFixture = { gameId: "private-newly-created", gameName: decoded.gameName() ?? "", gameType: "private", status: "draft", ownerUserId: "user-1", minPlayers: decoded.minPlayers(), maxPlayers: decoded.maxPlayers(), enrollmentEndsAtMs: decoded.enrollmentEndsAtMs(), createdAtMs: BigInt(Date.now()), updatedAtMs: BigInt(Date.now()), }; mocks.createGameCalls.push(created); mocks.state.myGames = [...mocks.state.myGames, created]; payload = buildGameCreateResponsePayload(created); break; } case "lobby.application.submit": { const builder = req.payloadBytes; const submitReq = await import("../../src/proto/galaxy/fbs/lobby"); const decoded = submitReq.ApplicationSubmitRequest.getRootAsApplicationSubmitRequest( new ByteBuffer(builder), ); const application: ApplicationFixture = { applicationId: `app-${mocks.applicationSubmitCalls.length + 1}`, gameId: decoded.gameId() ?? "", applicantUserId: "user-1", raceName: decoded.raceName() ?? "", status: "pending", createdAtMs: BigInt(Date.now()), }; mocks.applicationSubmitCalls.push({ gameId: application.gameId, raceName: application.raceName, }); mocks.state.applications = [application, ...mocks.state.applications]; payload = buildApplicationSubmitResponsePayload(application); break; } case "lobby.invite.redeem": { const redeemMod = await import("../../src/proto/galaxy/fbs/lobby"); const decoded = redeemMod.InviteRedeemRequest.getRootAsInviteRedeemRequest( new ByteBuffer(req.payloadBytes), ); const gameId = decoded.gameId() ?? ""; const inviteId = decoded.inviteId() ?? ""; mocks.inviteRedeemCalls.push({ gameId, inviteId }); const original = mocks.state.invitations.find((i) => i.inviteId === inviteId); const invite: InviteFixture = { ...(original ?? { inviteId, gameId, inviterUserId: "user-host", invitedUserId: "user-1", raceName: "", }), status: "accepted", decidedAtMs: BigInt(Date.now()), }; mocks.state.invitations = mocks.state.invitations.filter( (i) => i.inviteId !== inviteId, ); const newGame: GameFixture = { gameId, gameName: "Invited Game", gameType: "private", status: "enrollment_open", ownerUserId: "user-host", minPlayers: 2, maxPlayers: 8, enrollmentEndsAtMs: BigInt(Date.now() + 1_000_000), }; mocks.state.myGames = [...mocks.state.myGames, newGame]; payload = buildInviteRedeemResponsePayload(invite); break; } default: resultCode = "internal_error"; payload = new Uint8Array(); break; } const responseJson = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode, payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body: responseJson, }); }); await page.route( "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", async (route) => { const action = await new Promise<"endOfStream" | "abort">((resolve) => { mocks.pendingSubscribes.push(() => resolve("endOfStream")); }); if (action === "abort") { await route.abort(); return; } const body = new TextEncoder().encode("{}"); const frame = new Uint8Array(5 + body.length); frame[0] = 0x02; new DataView(frame.buffer).setUint32(1, body.length, false); frame.set(body, 5); await route.fulfill({ status: 200, contentType: "application/connect+json", body: Buffer.from(frame), }); }, ); return mocks; } async function completeLogin(page: Page): Promise { await page.goto("/"); await expect(page).toHaveURL(/\/login$/); // The login page renders the inputs `readonly` as a Safari // autofill-suppression workaround; the readonly attribute is // dropped on first focus. Playwright's `fill()` checks editability // before its own focus call, so emulate the user gesture explicitly: // click the input (focus → readonly drops), then fill. await page.getByTestId("login-email-input").click(); await page.getByTestId("login-email-input").fill("pilot@example.com"); await page.getByTestId("login-email-submit").click(); await expect(page.getByTestId("login-code-input")).toBeVisible(); await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); await expect(page).toHaveURL(/\/lobby$/); } test.describe("Phase 8 — lobby flow", () => { test("create-game flow lands the new game in My Games", async ({ page }) => { const mocks = await mockGateway(page); await completeLogin(page); await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible(); await page.getByTestId("lobby-create-button").click(); await expect(page).toHaveURL(/\/lobby\/create$/); await page.getByTestId("lobby-create-game-name").click(); await page.getByTestId("lobby-create-game-name").fill("First Contact"); await page.getByTestId("lobby-create-turn-schedule").click(); await page.getByTestId("lobby-create-turn-schedule").fill("0 0 * * *"); await page .getByTestId("lobby-create-enrollment-ends-at") .fill("2026-06-01T12:00"); await page.getByTestId("lobby-create-submit").click(); await expect(page).toHaveURL(/\/lobby$/); await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact"); expect(mocks.createGameCalls.length).toBe(1); expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact"); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("submitting an application produces a pending applications card", async ({ page, }) => { const mocks = await mockGateway(page, { publicGames: [ { gameId: "public-1", gameName: "Open Lobby", gameType: "public", status: "enrollment_open", }, ], }); await completeLogin(page); await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible(); await page.getByTestId("lobby-public-game-apply").click(); await page .getByTestId("lobby-application-race-name") .fill("Vegan Federation"); await page.getByTestId("lobby-application-submit").click(); await expect(page.getByTestId("lobby-application-card")).toBeVisible(); expect(mocks.applicationSubmitCalls).toEqual([ { gameId: "public-1", raceName: "Vegan Federation" }, ]); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("accepting an invitation removes it and adds the game to My Games", async ({ page, }) => { const mocks = await mockGateway(page, { invitations: [ { inviteId: "invite-1", gameId: "private-1", inviterUserId: "user-host", invitedUserId: "user-1", raceName: "Vegan Federation", status: "pending", }, ], }); await completeLogin(page); await expect(page.getByTestId("lobby-invite-accept")).toBeVisible(); await page.getByTestId("lobby-invite-accept").click(); await expect(page.getByTestId("lobby-invite-accept")).toBeHidden(); await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game"); expect(mocks.inviteRedeemCalls).toEqual([ { gameId: "private-1", inviteId: "invite-1" }, ]); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); });