import { expect, type Locator, type Page, type Route, test, } from "@playwright/test"; type RequestCapture = { loginBody?: Record; resetBody?: Record; resetToken?: string | null; clientLogs: string[]; }; const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/; const resetConfirmPasswordName = /^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/; const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/; async function enableFlutterAccessibility(page: Page): Promise { const button = page.getByRole("button", { name: "Enable accessibility" }); if (await button.count()) { await button.first().evaluate((node) => { (node as HTMLElement).click(); }); await page.waitForTimeout(200); return; } await page.waitForTimeout(300); const placeholder = page.locator("flt-semantics-placeholder").first(); if (await placeholder.count()) { await placeholder.evaluate((node) => { (node as HTMLElement).click(); }); 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 typeIntoAccessibleField( page: Page, field: Locator, value: string, ): Promise { await field.click({ 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 enableFlutterAccessibility(page); await page.getByRole("button", { name: "로그인" }).click({ force: true }); return; } await page.keyboard.press("Enter"); } async function fillResetPasswordForm( page: Page, password: string, ): Promise { await enableFlutterAccessibility(page); const newPasswordInput = page.getByRole("textbox", { name: resetNewPasswordName, }); const confirmPasswordInput = page.getByRole("textbox", { name: resetConfirmPasswordName, }); if ( (await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0 ) { await typeIntoAccessibleField(page, newPasswordInput, password); await typeIntoAccessibleField(page, confirmPasswordInput, password); return; } if (isMobileProject(page)) { await page .getByRole("textbox", { name: resetNewPasswordName }) .fill(password); await page .getByRole("textbox", { name: resetConfirmPasswordName }) .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 { await enableFlutterAccessibility(page); const submitButton = page.getByRole("button", { name: resetSubmitButtonName, }); if ((await submitButton.count()) > 0) { await submitButton.click({ force: true }); return; } if (isMobileProject(page)) { 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"); }); });