058c4fcf69
Tests · UI / test (push) Failing after 11m56s
`lobby-nav-overview` is replaced by `lobby-nav-games` (the new parent), and the empty-games active-past sub-panel is hidden entirely so the landing testid becomes `lobby-recruitment-empty` (the always-visible sub-panel for a no-games session). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
12 KiB
TypeScript
345 lines
12 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, the save-stay flow, and
|
|
// the time-zone dropdown.
|
|
|
|
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;
|
|
accountGetCount: number;
|
|
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 },
|
|
accountGetCount: 0,
|
|
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":
|
|
mocks.accountGetCount += 1;
|
|
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, stays on the form, and refreshes the identity strip", 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();
|
|
|
|
// Form stays on screen; the saved notice surfaces and the
|
|
// shell-level identity strip picks up the new name without a
|
|
// second `user.account.get`.
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
|
"Captain",
|
|
);
|
|
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
|
expect(mocks.settingsUpdates).toEqual([]);
|
|
|
|
// Editing the form again clears the notice so a follow-up save is
|
|
// unambiguous.
|
|
await page.getByTestId("profile-display-name").fill("Pilot");
|
|
await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("changing the language posts user.settings.update, stays on the form, 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();
|
|
|
|
// Profile stays on screen; the Russian dictionary now drives the
|
|
// form copy. The save button label is the visible signal.
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("profile-save")).toHaveText("сохранить");
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
expect(mocks.settingsUpdates).toHaveLength(1);
|
|
expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru");
|
|
|
|
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();
|
|
|
|
// Cancel returns to the lobby (resolver navigates to the first
|
|
// visible games sub-panel — recruitment for a no-games session).
|
|
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
|
|
expect(mocks.profileUpdates).toEqual([]);
|
|
expect(mocks.settingsUpdates).toEqual([]);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("time zone is a continent-grouped <select>; saving an edited zone posts user.settings.update", async ({
|
|
page,
|
|
}) => {
|
|
const mocks = await mockGateway(page, {
|
|
userId: "user-1",
|
|
email: "pilot@example.com",
|
|
userName: "player-abc12345",
|
|
displayName: "Pilot",
|
|
preferredLanguage: "en",
|
|
timeZone: "Europe/London",
|
|
});
|
|
await completeLogin(page);
|
|
await page.getByTestId("lobby-account-name").click();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
|
|
const select = page.getByTestId("profile-time-zone");
|
|
// The field renders as a <select> with at least the Europe and
|
|
// America optgroups present and the stored zone selected.
|
|
expect(await select.evaluate((el) => el.tagName)).toBe("SELECT");
|
|
const optgroupLabels = await select.evaluate((el) =>
|
|
Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map(
|
|
(g) => g.label,
|
|
),
|
|
);
|
|
expect(optgroupLabels).toContain("Europe");
|
|
expect(optgroupLabels).toContain("America");
|
|
await expect(select).toHaveValue("Europe/London");
|
|
|
|
await select.selectOption("America/New_York");
|
|
await page.getByTestId("profile-save").click();
|
|
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
expect(mocks.settingsUpdates).toEqual([
|
|
{ preferredLanguage: "en", timeZone: "America/New_York" },
|
|
]);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", 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");
|
|
const firstCount = mocks.accountGetCount;
|
|
expect(firstCount).toBeGreaterThanOrEqual(1);
|
|
|
|
// Navigate Games → Profile: identity must NOT flash the
|
|
// loading placeholder, and the cache must answer without a
|
|
// second gateway call.
|
|
await page.getByTestId("lobby-nav-profile").click();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
|
|
|
// Navigate back to the games section. F8-04b replaced the bare
|
|
// `Overview` page with a `games` parent + sub-panels; clicking
|
|
// the parent resolves to the first visible sub-panel
|
|
// (`recruitment` for a no-games session).
|
|
await page.getByTestId("lobby-nav-games").click();
|
|
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
|
|
|
expect(mocks.accountGetCount).toBe(firstCount);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
});
|