import { expect, test, type Page, type Route } 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 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 (!isMobileProject(page)) { if ((await textbox.count()) > 0) { await textbox.click({ force: true }); await page.waitForTimeout(100); } const coords = coordsFor(page); await fillAt(page, coords.departmentInputX, coords.departmentInputY, value); return; } if ((await textbox.count()) > 0) { await textbox.click({ force: true }); await page.waitForTimeout(100); await replaceFocusedText(page, value); 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'); }); });