// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the // lobby boots with an account aggregate, then exercises the sidebar // navigation into the profile, the edit form, and the save round-trip // against the FlatBuffers-decoded `user.profile.update` / // `user.settings.update` payloads. import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { ByteBuffer } from "flatbuffers"; import { expect, test, type Page } from "@playwright/test"; import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; import { UpdateMyProfileRequest, UpdateMySettingsRequest, } from "../../src/proto/galaxy/fbs/user"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { buildAccountResponsePayload, buildMyApplicationsListPayload, buildMyGamesListPayload, buildMyInvitesListPayload, buildPublicGamesListPayload, type AccountFixture, } from "./fixtures/lobby-fbs"; interface ProfileMocks { pendingSubscribes: Array<() => void>; account: AccountFixture; profileUpdates: Array<{ displayName: string }>; settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>; } async function mockGateway( page: Page, initial: AccountFixture, ): Promise { const mocks: ProfileMocks = { pendingSubscribes: [], account: { ...initial }, profileUpdates: [], settingsUpdates: [], }; 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("**/edge.v1.Gateway/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 payload: Uint8Array; switch (req.messageType) { case "user.account.get": payload = buildAccountResponsePayload(mocks.account); break; case "user.profile.update": { const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest( new ByteBuffer(req.payloadBytes), ); const next = decoded.displayName() ?? ""; mocks.profileUpdates.push({ displayName: next }); mocks.account = { ...mocks.account, displayName: next }; payload = buildAccountResponsePayload(mocks.account); break; } case "user.settings.update": { const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest( new ByteBuffer(req.payloadBytes), ); const preferredLanguage = decoded.preferredLanguage() ?? ""; const timeZone = decoded.timeZone() ?? ""; mocks.settingsUpdates.push({ preferredLanguage, timeZone }); mocks.account = { ...mocks.account, preferredLanguage, timeZone }; payload = buildAccountResponsePayload(mocks.account); break; } case "lobby.my.games.list": payload = buildMyGamesListPayload([]); break; case "lobby.public.games.list": payload = buildPublicGamesListPayload([]); break; case "lobby.my.invites.list": payload = buildMyInvitesListPayload([]); break; case "lobby.my.applications.list": payload = buildMyApplicationsListPayload([]); break; default: payload = new Uint8Array(); } const responseJson = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode: "ok", payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body: responseJson, }); }); await page.route("**/edge.v1.Gateway/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.getByTestId("login-email-input")).toBeVisible(); 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.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("F8-04 — profile screen", () => { test("clicking the identity strip opens the profile and renders the form", async ({ page, }) => { const mocks = await mockGateway(page, { userId: "user-1", email: "pilot@example.com", userName: "player-abc12345", displayName: "Pilot", }); await completeLogin(page); await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); await page.getByTestId("lobby-account-name").click(); await expect(page.getByTestId("profile-form")).toBeVisible(); await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot"); await expect(page.getByTestId("profile-identity")).toContainText( "player-abc12345", ); await expect(page.getByTestId("profile-identity")).toContainText( "pilot@example.com", ); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("saving an edited display name posts user.profile.update and returns to lobby", async ({ page, }) => { const mocks = await mockGateway(page, { userId: "user-1", email: "pilot@example.com", userName: "player-abc12345", displayName: "Pilot", }); await completeLogin(page); await page.getByTestId("lobby-account-name").click(); await expect(page.getByTestId("profile-form")).toBeVisible(); await page.getByTestId("profile-display-name").fill("Captain"); await page.getByTestId("profile-save").click(); await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); await expect(page.getByTestId("lobby-account-name")).toContainText( "Captain", ); expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]); expect(mocks.settingsUpdates).toEqual([]); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("changing the language posts user.settings.update and switches the active locale", async ({ page, }) => { const mocks = await mockGateway(page, { userId: "user-1", email: "pilot@example.com", userName: "player-abc12345", displayName: "Pilot", }); await completeLogin(page); await page.getByTestId("lobby-account-name").click(); await page.getByTestId("profile-preferred-language").selectOption("ru"); await page.getByTestId("profile-save").click(); await expect(page.getByTestId("lobby-account-name")).toBeVisible(); // The lobby switches to the Russian dictionary after the save — // the "create new game" button label is the visible signal. await expect(page.getByTestId("lobby-create-button")).toHaveText( "создать новую игру", ); expect(mocks.settingsUpdates).toEqual([ { preferredLanguage: "ru", timeZone: "UTC" }, ]); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("cancel returns to the lobby without posting anything", async ({ page, }) => { const mocks = await mockGateway(page, { userId: "user-1", email: "pilot@example.com", userName: "player-abc12345", displayName: "Pilot", }); await completeLogin(page); await page.getByTestId("lobby-account-name").click(); await expect(page.getByTestId("profile-form")).toBeVisible(); await page.getByTestId("profile-display-name").fill("ignored"); await page.getByTestId("profile-cancel").click(); await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); expect(mocks.profileUpdates).toEqual([]); expect(mocks.settingsUpdates).toEqual([]); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); });