import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalOverflow).toBe(false); } async function zoomTo200Percent(page: Page): Promise { await page.evaluate(() => { document.documentElement.style.zoom = "2"; }); } async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise { const results = await new AxeBuilder({ page }).analyze(); const blockingViolations = results.violations.filter( (violation) => violation.impact === "critical" || violation.impact === "serious", ); expect(blockingViolations).toEqual([]); } async function pressTabUntilFocused(page: Page, target: Locator): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) { return; } await page.keyboard.press("Tab"); } await expect(target).toBeFocused(); } async function loginThroughMockMode(page: Page): Promise { await page.goto("/login"); await page.getByLabel("이메일").fill("admin@test.com"); await page.getByLabel("비밀번호").fill("password"); await page.getByRole("button", { name: "로그인" }).click(); await expect(page).toHaveURL(/\/ai-characters$/); await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); } async function openFirstUnansweredFanTalk(page: Page): Promise { await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); return page.getByRole("dialog", { name: "FanTalk 답변" }); } async function expectFanTalkListReady(page: Page): Promise { await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible(); await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible(); await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); } test("FanTalk mock journey creates edits and deletes through list-backed endpoints", async ({ page }) => { // Given const fanTalkRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = []; page.on("request", (request) => { const url = new URL(request.url()); if (url.pathname.startsWith("/api/v2/admin/") && url.pathname.includes("/fan-talks")) { fanTalkRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search }); } }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/fan-talks?page=0&size=20&status=pending&sort=createdAt"); // Then await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible(); await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible(); await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); await expect(page.locator("body")).toContainText("2026. 07. 28. 10:00"); await expect(page.locator("body")).toContainText("2026. 07. 28. 11:00"); await expect(page.locator("body")).not.toContainText("2026-07-28T01:00:00Z"); await expect(page.locator("body")).not.toContainText("2026-07-28T02:00:00Z"); expect(fanTalkRequests).toContainEqual({ body: null, method: "GET", path: "/api/v2/admin/ai-characters/101/fan-talks", search: "?page=0&size=20" }); // When await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); const dialog = page.getByRole("dialog", { name: "FanTalk 답변" }); await dialog.getByLabel("답변 내용").fill("응원 고마워요."); await dialog.getByRole("button", { name: "답변 등록" }).click(); // Then const createStatus = dialog.getByRole("status", { name: "답변 저장 성공" }); await expect(createStatus).toContainText("답변이 등록되었습니다."); await expect(createStatus).not.toContainText("fanTalk"); await expect(createStatus).not.toContainText("reply"); await expect(createStatus).not.toContainText("creator"); await expect(createStatus).not.toContainText("2026-07-28T04:00:00Z"); await expect(dialog).toContainText("응원 고마워요."); await expect(dialog).toContainText("2026. 07. 28. 13:00"); await expect(dialog).not.toContainText("2026-07-28T04:00:00Z"); await expect(dialog.getByLabel("답변 내용")).toBeHidden(); await dialog.getByRole("button", { name: "닫기" }).click(); expect(fanTalkRequests.filter((request) => request.method === "POST" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies" && request.body === JSON.stringify({ content: "응원 고마워요." }))).toHaveLength(1); // When await page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" }).click(); const createdReplyDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); await createdReplyDialog.getByLabel("답변 내용").fill("방금 등록한 답변을 수정합니다."); await createdReplyDialog.getByRole("button", { name: "답변 수정" }).click(); // Then await expect(createdReplyDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다."); await createdReplyDialog.getByRole("button", { name: "닫기" }).click(); await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeVisible(); expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies/9001" && request.body === JSON.stringify({ content: "방금 등록한 답변을 수정합니다." }))).toHaveLength(1); // When await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click(); const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); await expect(editDialog.getByLabel("답변 내용")).toHaveValue("이미 답변했습니다."); await editDialog.getByLabel("답변 내용").fill("수정한 답변입니다."); await editDialog.getByRole("button", { name: "답변 수정" }).click(); // Then const updateStatus = editDialog.getByRole("status", { name: "답변 저장 성공" }); await expect(updateStatus).toContainText("답변이 수정되었습니다."); await expect(updateStatus).not.toContainText("reply"); await expect(updateStatus).not.toContainText("2026-07-28T05:00:00Z"); expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002/replies/7102" && request.body === JSON.stringify({ content: "수정한 답변입니다." }))).toHaveLength(1); expect(fanTalkRequests.every((request) => request.body === null || !request.body.includes("isActive"))).toBe(true); // When await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click(); await page.getByRole("button", { name: "삭제 확인" }).click(); // Then await expect(editDialog).toBeHidden(); await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeHidden(); expect(fanTalkRequests.filter((request) => request.method === "DELETE" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002" && request.body === null)).toHaveLength(1); expect(fanTalkRequests.some((request) => request.method === "GET" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001")).toBe(false); expect(fanTalkRequests.every((request) => !request.search.includes("status=") && !request.search.includes("sort="))).toBe(true); }); for (const width of [320, 768, 1280] as const) { test(`FanTalk create edit and delete stay available at ${width}px`, async ({ page }) => { // Given await page.setViewportSize({ width, height: 640 }); await loginThroughMockMode(page); // When / Then await page.goto("/ai-characters/101/fan-talks"); await expectFanTalkListReady(page); await expectNoHorizontalOverflow(page); const dialog = await openFirstUnansweredFanTalk(page); await expect(dialog).toBeVisible(); await expect(dialog.getByLabel("답변 내용")).toBeVisible(); await expect(dialog.getByRole("button", { name: "답변 등록" })).toBeVisible(); await dialog.getByLabel("답변 내용").fill(`${width}px 응답입니다.`); await dialog.getByRole("button", { name: "답변 등록" }).click(); await expect(dialog).toContainText(`${width}px 응답입니다.`); await expect(dialog.getByLabel("답변 내용")).toBeHidden(); await dialog.getByRole("button", { name: "닫기" }).click(); await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click(); const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); await expect(editDialog.getByRole("button", { name: "답변 수정" })).toBeVisible(); await expect(editDialog.getByRole("button", { name: "FanTalk 원글 삭제" })).toBeVisible(); await editDialog.getByLabel("답변 내용").fill(`${width}px 수정입니다.`); await editDialog.getByRole("button", { name: "답변 수정" }).click(); await expect(editDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다."); await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click(); await page.getByRole("button", { name: "삭제 확인" }).click(); await expect(editDialog).toBeHidden(); }); } test("320px FanTalk reply sheet keeps input and submit usable in a keyboard viewport", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 480 }); await loginThroughMockMode(page); await page.goto("/ai-characters/101/fan-talks"); // When / Then const dialog = await openFirstUnansweredFanTalk(page); const replyInput = dialog.getByLabel("답변 내용"); const submitButton = dialog.getByRole("button", { name: "답변 등록" }); await expect(replyInput).toBeInViewport(); await replyInput.focus(); await expect(submitButton).toBeInViewport(); await replyInput.fill("좁은 화면에서도 등록합니다."); await submitButton.click(); await expect(dialog).toContainText("좁은 화면에서도 등록합니다."); await expectNoHorizontalOverflow(page); }); test("keyboard-only FanTalk flow opens sheet creates reply and restores focus", async ({ browserName, page }) => { test.skip(browserName === "webkit", "WebKit does not consistently tab-focus buttons in this keyboard path."); // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); await page.goto("/ai-characters/101/fan-talks"); const opener = page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }); // When / Then await pressTabUntilFocused(page, opener); await page.keyboard.press("Enter"); const dialog = page.getByRole("dialog", { name: "FanTalk 답변" }); const closeButton = dialog.getByRole("button", { name: "닫기" }); await expect(closeButton).toBeFocused(); const replyInput = dialog.getByLabel("답변 내용"); await pressTabUntilFocused(page, replyInput); await page.keyboard.type("키보드로 작성한 답변입니다."); await pressTabUntilFocused(page, dialog.getByRole("button", { name: "답변 등록" })); await page.keyboard.press("Enter"); await expect(dialog.getByRole("status", { name: "답변 저장 성공" })).toBeVisible(); await expect(dialog).toContainText("키보드로 작성한 답변입니다."); await expect(replyInput).toBeHidden(); await pressTabUntilFocused(page, closeButton); await page.keyboard.press("Enter"); await expect(dialog).toBeHidden(); await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeFocused(); }); test("FanTalk route keeps 200 percent zoom and axe coverage across viewport widths", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 768, 1280] as const) { await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters/101/fan-talks"); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } });