Files
galaxy-game/ui/frontend/tests/e2e/profile-screen.spec.ts
T
Ilia Denisov 5271f2b1ec
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m30s
feat(ui): lobby site-style sidebar + profile screen (#47)
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
2026-05-26 13:42:10 +02:00

261 lines
8.4 KiB
TypeScript

// 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<ProfileMocks> {
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<void> {
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());
});
});