import { expect, test, type Page, type Route } from '@playwright/test'; type RequestCapture = { loginBody?: Record; resetBody?: Record; resetToken?: string | null; clientLogs: string[]; }; async function enableFlutterAccessibility(page: Page): Promise { await page.waitForTimeout(300); const button = page.getByRole('button', { name: 'Enable accessibility' }); if (await button.count()) { await button.click({ force: true }); const placeholder = page.locator('flt-semantics-placeholder'); if (await placeholder.count()) { await placeholder.first().click({ force: true }); } await page.waitForTimeout(800); } } type ScreenCoords = { signinPasswordTabX: number; signinTabY: number; signinLoginIdX: number; signinLoginIdY: number; signinPasswordX: number; signinPasswordY: number; signinSubmitX: number; signinSubmitY: number; resetNewPasswordX: number; resetNewPasswordY: number; resetConfirmPasswordX: number; resetConfirmPasswordY: number; resetSubmitX: number; resetSubmitY: number; }; const desktopCoords: ScreenCoords = { signinPasswordTabX: 522, signinTabY: 158, signinLoginIdX: 640, signinLoginIdY: 245, signinPasswordX: 640, signinPasswordY: 311, signinSubmitX: 640, signinSubmitY: 381, resetNewPasswordX: 640, resetNewPasswordY: 382, resetConfirmPasswordX: 640, resetConfirmPasswordY: 464, resetSubmitX: 640, resetSubmitY: 534, }; const mobileCoords: ScreenCoords = { signinPasswordTabX: 90, signinTabY: 158, signinLoginIdX: 206, signinLoginIdY: 268, signinPasswordX: 206, signinPasswordY: 334, signinSubmitX: 206, signinSubmitY: 399, resetNewPasswordX: 206, resetNewPasswordY: 382, resetConfirmPasswordX: 206, resetConfirmPasswordY: 464, resetSubmitX: 206, resetSubmitY: 534, }; function coordsFor(page: Page): ScreenCoords { 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 clickPasswordTab(page: Page): Promise { if (isMobileProject(page)) { return; } const coords = coordsFor(page); await page.waitForTimeout(900); const pane = page.locator('flt-glass-pane'); await pane.click({ position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(120); await pane.click({ position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(200); } 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 page.keyboard.press('Control+A'); await page.keyboard.press('Backspace'); await page.keyboard.type(value); } async function fillPasswordLoginForm( page: Page, loginId: string, password: string, ): Promise { if (isMobileProject(page)) { await enableFlutterAccessibility(page); const inputs = page.getByRole('textbox'); await inputs.nth(0).fill(loginId); await inputs.nth(1).fill(password); return; } const coords = coordsFor(page); await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId); await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password); } async function submitPasswordLogin(page: Page): Promise { if (isMobileProject(page)) { await page.getByRole('button', { name: '로그인' }).click({ force: true }); return; } const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, force: true, }); } async function fillResetPasswordForm(page: Page, password: string): Promise { if (isMobileProject(page)) { await enableFlutterAccessibility(page); await page .getByRole('textbox', { name: /^새 비밀번호$/ }) .fill(password); await page .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) .fill(password); return; } const coords = coordsFor(page); await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password); await fillAt( page, coords.resetConfirmPasswordX, coords.resetConfirmPasswordY, password, ); } async function submitResetPassword(page: Page): Promise { if (isMobileProject(page)) { await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); return; } const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ position: { x: coords.resetSubmitX, y: coords.resetSubmitY }, force: true, }); } async function mockAuthApis(page: Page, capture: RequestCapture): Promise { await page.route('**/api/v1/**', async (route: Route) => { const requestUrl = new URL(route.request().url()); const path = requestUrl.pathname; if (path.endsWith('/api/v1/auth/password/login')) { capture.loginBody = (route.request().postDataJSON() ?? {}) as Record< string, unknown >; const loginId = String(capture.loginBody.loginId ?? ''); const password = String(capture.loginBody.password ?? ''); if (loginId === 'e2e@example.com' && password === 'ValidPass1!') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ sessionJwt: 'e30.e30.e30', provider: 'ory', }), }); return; } await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'password_or_email_mismatch' }), }); return; } if (path.endsWith('/api/v1/auth/password/policy')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ minLength: 12, minCharacterTypes: 3, lowercase: true, uppercase: true, number: true, nonAlphanumeric: true, }), }); return; } if (path.endsWith('/api/v1/auth/password/reset/complete')) { capture.resetBody = (route.request().postDataJSON() ?? {}) as Record< string, unknown >; capture.resetToken = requestUrl.searchParams.get('token'); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok' }), }); return; } if (path.endsWith('/api/v1/client-log')) { const payload = (route.request().postDataJSON() ?? {}) as { message?: string; }; if (payload.message != null) { capture.clientLogs.push(payload.message); } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }), }); return; } if (path.endsWith('/api/v1/user/me')) { const authHeader = route.request().headers()['authorization'] ?? ''; if (!authHeader.startsWith('Bearer ')) { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'unauthorized' }), }); return; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'e2e-user', email: 'e2e@example.com', name: 'E2E User', phone: '+821012341234', department: 'QA', affiliationType: 'employee', companyCode: 'BARON', tenant: { id: 'tenant-1', name: 'Baron', slug: 'baron', description: 'E2E tenant', }, }), }); 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; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}), }); }); } test.describe('UserFront WASM password login and reset', () => { test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)'); test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => { test.skip( isMobileProject(page), 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!'); await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/dashboard$/); expect(capture.loginBody?.loginId).toBe('e2e@example.com'); expect(capture.loginBody?.password).toBe('ValidPass1!'); const storedToken = await page.evaluate(() => window.localStorage.getItem('baron_auth_token'), ); expect(storedToken).toBe('e30.e30.e30'); }); test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => { test.skip( isMobileProject(page), 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!'); await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/signin$/); await expect .poll( () => capture.clientLogs.some((message) => message.includes('password_or_email_mismatch'), ), { timeout: 10000 }, ) .toBe(true); }); test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => { const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); const policyLoaded = page.waitForResponse( (response) => response.url().includes('/api/v1/auth/password/policy') && response.status() === 200, ); await page.goto('/ko/reset-password?token=reset-token-e2e'); await policyLoaded; await page.waitForTimeout(900); await fillResetPasswordForm(page, 'ValidPass1!A'); await submitResetPassword(page); await expect .poll( () => capture.resetBody?.newPassword as string | undefined, { timeout: 10000 }, ) .toBe('ValidPass1!A'); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 }); expect(capture.resetToken).toBe('reset-token-e2e'); expect(capture.resetBody?.newPassword).toBe('ValidPass1!A'); }); });