import { expect, type Page, type Route, test } from "@playwright/test"; type ProfileState = { department: string; getMeCount: number; putBodies: Array>; }; async function enableFlutterAccessibility(page: Page): Promise { const button = page.getByRole("button", { name: "Enable accessibility" }); if (await button.count()) { await button.click({ force: true }).catch(async () => { await page .locator('flt-semantics-placeholder[aria-label="Enable accessibility"]') .evaluate((element) => { if (element instanceof HTMLElement) element.click(); }); }); await page.waitForTimeout(200); } } type ProfileCoords = { departmentEditX: number; departmentEditY: number; departmentInputX: number; departmentInputY: number; blurX: number; blurY: number; }; const desktopCoords: ProfileCoords = { departmentEditX: 1170, departmentEditY: 680, departmentInputX: 110, departmentInputY: 685, blurX: 200, blurY: 260, }; const mobileCoords: ProfileCoords = { departmentEditX: 350, departmentEditY: 680, departmentInputX: 110, departmentInputY: 685, blurX: 200, blurY: 260, }; function coordsFor(page: Page): ProfileCoords { const viewport = page.viewportSize(); return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords; } function isMobileProject(page: Page): boolean { const viewport = page.viewportSize(); return (viewport?.width ?? 1280) <= 500; } async function seedTokenLogin(page: Page): Promise { await page.addInitScript(() => { window.localStorage.setItem("baron_auth_token", "e30.e30.e30"); window.localStorage.setItem("baron_auth_provider", "ory"); window.localStorage.removeItem("baron_auth_cookie_mode"); window.localStorage.removeItem("baron_auth_pending_provider"); }); } async function fillAt( page: Page, x: number, y: number, value: string, ): Promise { const pane = page.locator("flt-glass-pane"); await pane.click({ position: { x, y }, force: true }); await page.waitForTimeout(100); await replaceFocusedText(page, value); } async function replaceFocusedText(page: Page, value: string): Promise { await page.keyboard.press("End"); for (let index = 0; index < 64; index += 1) { await page.keyboard.press("Backspace"); } if (value !== "") { await page.keyboard.insertText(value); } await page.waitForTimeout(100); } type BoxCenter = { x: number; y: number; }; async function resolveLocatorCenter( locator: ReturnType, ): Promise { const handle = await locator .elementHandle({ timeout: 1_000 }) .catch(() => null); if (!handle) { return null; } const box = await handle .evaluate((element) => { const rect = element.getBoundingClientRect(); return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }; }) .catch(() => null); await handle.dispose(); if (!box) { return null; } return { x: box.x + box.width / 2, y: box.y + box.height / 2, }; } async function clickGlassPaneAt( page: Page, center: BoxCenter | null, ): Promise { if (!center) { return false; } await page.locator("flt-glass-pane").click({ position: center, force: true, }); await page.waitForTimeout(200); return true; } async function departmentTextboxIsOpen(page: Page): Promise { return (await page.getByRole("textbox", { name: "소속" }).count()) > 0; } async function openDepartmentEditor(page: Page): Promise { const accessibleEditor = page .getByRole("group", { name: "소속 QA" }) .getByRole("button", { name: "편집" }); const textbox = page.getByRole("textbox", { name: "소속" }); if ((await accessibleEditor.count()) > 0) { const editorCenter = await resolveLocatorCenter(accessibleEditor); await accessibleEditor .evaluate( (element) => { if (element instanceof HTMLElement) { element.click(); } }, { timeout: 1_000 }, ) .catch(() => undefined); await page.waitForTimeout(200); if (await departmentTextboxIsOpen(page)) { return; } await clickGlassPaneAt(page, editorCenter); if (await departmentTextboxIsOpen(page)) { return; } await accessibleEditor .click({ force: true, timeout: 1_000 }) .catch(() => undefined); await page.waitForTimeout(200); if (await departmentTextboxIsOpen(page)) { return; } } if (isMobileProject(page)) { throw new Error("Department editor accessibility button was not found."); } const coords = coordsFor(page); const viewport = page.viewportSize(); const editCandidates: BoxCenter[] = [ { x: coords.departmentEditX, y: coords.departmentEditY }, { x: (viewport?.width ?? 1280) - 110, y: coords.departmentEditY }, { x: coords.departmentEditX - 24, y: coords.departmentEditY }, { x: coords.departmentEditX + 24, y: coords.departmentEditY }, ]; for (const candidate of editCandidates) { await clickGlassPaneAt(page, candidate); if (await departmentTextboxIsOpen(page)) { return; } } await expect(textbox).toHaveCount(1, { timeout: 1_000 }); } async function blurDepartmentEditor(page: Page): Promise { const textbox = page.getByRole("textbox", { name: "소속" }); if ((await textbox.count()) > 0) { await textbox.blur(); await page.waitForTimeout(250); return; } if (isMobileProject(page)) { throw new Error("Department textbox was not found."); } const coords = coordsFor(page); await page.locator("flt-glass-pane").click({ position: { x: coords.blurX, y: coords.blurY }, force: true, }); await page.waitForTimeout(250); } async function submitDepartmentEditor(page: Page): Promise { const saveButton = page.getByRole("button", { name: "저장" }); if ((await saveButton.count()) > 0) { await saveButton.click({ force: true }); await page.waitForTimeout(250); return; } const textbox = page.getByRole("textbox", { name: "소속" }); if ((await textbox.count()) > 0) { await textbox.press("Enter"); await page.waitForTimeout(250); return; } if (isMobileProject(page)) { throw new Error("Department textbox was not found."); } await page.keyboard.press("Enter"); await page.waitForTimeout(250); } async function fillDepartmentField(page: Page, value: string): Promise { const textbox = page.getByRole("textbox", { name: "소속" }); if ((await textbox.count()) > 0) { await textbox.fill(value); await page.waitForTimeout(100); return; } if (isMobileProject(page)) { throw new Error("Department textbox was not found."); } const coords = coordsFor(page); await fillAt(page, coords.departmentInputX, coords.departmentInputY, value); } async function mockProfileApis(page: Page, state: ProfileState): Promise { await page.route("**/api/v1/**", async (route: Route) => { const request = route.request(); const requestUrl = new URL(request.url()); const path = requestUrl.pathname; const method = request.method().toUpperCase(); if (path.endsWith("/api/v1/user/me") && method === "GET") { const authHeader = request.headers().authorization ?? ""; if (!authHeader.startsWith("Bearer ")) { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "unauthorized" }), }); return; } state.getMeCount += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id: "e2e-user", email: "e2e@example.com", name: "E2E User", phone: "+821012341234", department: state.department, affiliationType: "employee", companyCode: "BARON", tenant: { id: "tenant-1", name: "Baron", slug: "baron", description: "E2E tenant", }, }), }); return; } if (path.endsWith("/api/v1/user/me") && method === "PUT") { const body = (request.postDataJSON() ?? {}) as Record; state.putBodies.push(body); const nextDepartment = String(body.department ?? "").trim(); if (nextDepartment !== "") { state.department = nextDepartment; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "success", updatedAt: "2026-02-24T00:00:00Z", }), }); return; } if (path.endsWith("/api/v1/user/rp/linked")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }); return; } if (path.endsWith("/api/v1/audit/auth/timeline")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], next_cursor: "" }), }); return; } if (path.endsWith("/api/v1/client-log")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); }); } async function openProfilePage(page: Page): Promise { await page.goto("/ko/profile"); await expect(page).toHaveURL(/\/ko\/profile$/); await enableFlutterAccessibility(page); await page.waitForTimeout(1200); } async function waitForInitialProfileLoad(state: ProfileState): Promise { await expect.poll(() => state.getMeCount).toBeGreaterThan(0); } test.describe("UserFront WASM profile department editing", () => { test.skip(({ isMobile }) => isMobile, "Desktop only (hardcoded coordinates)"); test.skip( ({ browserName }) => browserName === "webkit", "WebKit headless does not consistently open Flutter profile edit controls; Chromium and Firefox cover this flow.", ); test.afterEach(async ({ page }) => { await page.unroute("**/api/v1/**"); }); test("소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다", async ({ page, }) => { const state: ProfileState = { department: "QA", getMeCount: 0, putBodies: [], }; await seedTokenLogin(page); await mockProfileApis(page, state); await openProfilePage(page); await waitForInitialProfileLoad(state); await openDepartmentEditor(page); await fillDepartmentField(page, "QA-Updated"); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); expect(state.putBodies[0]?.department).toBe("QA-Updated"); expect(state.department).toBe("QA-Updated"); const getCountBeforeReload = state.getMeCount; await page.reload(); await expect(page).toHaveURL(/\/ko\/profile$/); await expect .poll(() => state.getMeCount) .toBeGreaterThan(getCountBeforeReload); }); test("소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다", async ({ page, }) => { const state: ProfileState = { department: "QA", getMeCount: 0, putBodies: [], }; await seedTokenLogin(page); await mockProfileApis(page, state); await openProfilePage(page); await waitForInitialProfileLoad(state); await openDepartmentEditor(page); await fillDepartmentField(page, "QA-Repro"); await page.reload(); await expect(page).toHaveURL(/\/ko\/profile$/); expect(state.putBodies.length).toBeLessThanOrEqual(1); if (state.putBodies.length > 0) { expect(state.putBodies[0]?.department).toBe("QA-Repro"); expect(state.department).toBe("QA-Repro"); return; } expect(state.department).toBe("QA"); }); test("소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다", async ({ page, }) => { const state: ProfileState = { department: "QA", getMeCount: 0, putBodies: [], }; await seedTokenLogin(page); await mockProfileApis(page, state); await openProfilePage(page); await waitForInitialProfileLoad(state); await openDepartmentEditor(page); await fillDepartmentField(page, "QA"); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); }); test("소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다", async ({ page, }) => { const state: ProfileState = { department: "QA", getMeCount: 0, putBodies: [], }; await seedTokenLogin(page); await mockProfileApis(page, state); await openProfilePage(page); await waitForInitialProfileLoad(state); await openDepartmentEditor(page); await fillDepartmentField(page, ""); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); expect(state.department).toBe("QA"); }); test("소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다", async ({ page, }) => { const state: ProfileState = { department: "QA", getMeCount: 0, putBodies: [], }; await seedTokenLogin(page); await mockProfileApis(page, state); await openProfilePage(page); await waitForInitialProfileLoad(state); await openDepartmentEditor(page); await fillDepartmentField(page, "QA-1"); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); const getCountBeforeReload = state.getMeCount; await page.reload(); await expect(page).toHaveURL(/\/ko\/profile$/); await enableFlutterAccessibility(page); await expect .poll(() => state.getMeCount) .toBeGreaterThan(getCountBeforeReload); await page.waitForTimeout(1200); await openDepartmentEditor(page); await fillDepartmentField(page, "QA-2"); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(2); expect(state.putBodies[0]?.department).toBe("QA-1"); expect(state.putBodies[1]?.department).toBe("QA-2"); expect(state.department).toBe("QA-2"); }); });