import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; const interactiveActionRoles = ["button", "link", "menuitem"] as const; 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 expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalOverflow).toBe(false); } 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 expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise { for (const role of interactiveActionRoles) { await expect(page.getByRole(role, { name })).toBeHidden(); } } async function zoomTo200Percent(page: Page): Promise { await page.evaluate(() => { document.documentElement.style.zoom = "2"; }); } async function pressTabUntilFocused(page: Page, target: Locator): Promise { for (let attempt = 0; attempt < 80; attempt += 1) { if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) { return; } await page.keyboard.press("Tab"); } await expect(target).toBeFocused(); } test("desktop and tablet expose the create management action from the list", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [1280, 768]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters"); await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); // When / Then await expect(page.getByRole("link", { name: "AI 캐릭터 생성" })).toBeVisible(); } }); test("desktop and tablet expose edit and deactivate management actions on detail", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [1280, 768]) { await page.setViewportSize({ width, height: 900 }); // When await page.goto("/ai-characters/101"); // Then await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "수정" })).toBeVisible(); await expect(page.getByRole("button", { name: "비활성화" })).toBeVisible(); } }); test("mobile supports list, search, and detail while hiding detail mutation actions", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When await expect(page.getByRole("link", { name: "AI 캐릭터 생성" })).toBeVisible(); await page.getByLabel("검색어").fill("루나"); await expect(page.getByRole("link", { name: "루나 선택" })).toBeVisible(); await page.getByRole("link", { name: "루나 선택" }).click(); // Then await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible(); await expectActionAbsentAcrossInteractiveRoles(page, "수정"); await expectActionAbsentAcrossInteractiveRoles(page, "비활성화"); }); test("mobile direct create route shows desktop guidance instead of the create form", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/new"); // Then await expect(page.getByText(/데스크톱.*(생성|관리|이용)/)).toBeVisible(); await expect(page.getByRole("form", { name: "AI 캐릭터 생성 form" })).toBeHidden(); }); test("mobile direct edit route shows desktop guidance instead of the edit form", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/edit"); // Then await expect(page.getByText(/데스크톱.*(수정|관리|이용)/)).toBeVisible(); await expect(page.getByRole("form", { name: "AI 캐릭터 수정 form" })).toBeHidden(); }); test("direct create and edit routes keep accessibility coverage at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => { test.setTimeout(60_000); // Given await loginThroughMockMode(page); for (const [width, route, formName, guidancePattern] of [ [320, "/ai-characters/new", "AI 캐릭터 생성 form", /데스크톱.*(생성|관리|이용)/], [768, "/ai-characters/new", "AI 캐릭터 생성 form", null], [1280, "/ai-characters/new", "AI 캐릭터 생성 form", null], [320, "/ai-characters/101/edit", "AI 캐릭터 수정 form", /데스크톱.*(수정|관리|이용)/], [768, "/ai-characters/101/edit", "AI 캐릭터 수정 form", null], [1280, "/ai-characters/101/edit", "AI 캐릭터 수정 form", null], ] as const) { await page.setViewportSize({ width, height: 900 }); // When await page.goto(route); if (guidancePattern) { // Then await expect(page.getByText(guidancePattern)).toBeVisible(); await expect(page.getByRole("form", { name: formName })).toBeHidden(); if (width === 320) { // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } continue; } await expect(page.getByRole("heading", { name: route === "/ai-characters/new" ? "AI 캐릭터 생성" : "AI 캐릭터 수정" })).toBeVisible(); await expect(page.getByRole("form", { name: formName })).toBeVisible(); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } }); test("mobile cards preserve the selection name and table-visible core metadata", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When const lunaCard = page.getByRole("link", { name: "루나 선택" }); // Then await expect(lunaCard).toBeVisible(); await expect(lunaCard).toContainText("루나"); await expect(lunaCard).toContainText("차분한 상담형 AI 캐릭터"); await expect(lunaCard).toContainText("ID 101"); await expect(lunaCard).toContainText("KR"); await expect(lunaCard).toContainText("상담, 힐링"); }); test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => { test.skip(isMobile, "Mobile view intentionally hides Character mutation actions."); test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path."); // Given await loginThroughMockMode(page); const searchInput = page.getByLabel("검색어"); // When await searchInput.focus(); await page.keyboard.type("루나"); await expect(page.getByRole("link", { name: "루나", exact: true })).toBeVisible(); await pressTabUntilFocused(page, page.getByRole("link", { name: "루나", exact: true })); await page.keyboard.press("Enter"); await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible(); await pressTabUntilFocused(page, page.getByRole("link", { name: "프로필" })); await pressTabUntilFocused(page, page.getByRole("button", { name: "수정" })); await page.keyboard.press("Enter"); await expect(page.getByRole("heading", { name: "AI 캐릭터 수정" })).toBeVisible(); await page.getByLabel("이름").focus(); await page.keyboard.type(" 키보드"); await pressTabUntilFocused(page, page.getByRole("button", { name: "상세로 돌아가기" })); await page.keyboard.press("Enter"); // Then await expect(page.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" })).toBeVisible(); await expect(page.getByRole("button", { name: "계속 편집" })).toBeFocused(); }); test("has no critical or serious axe violations on the list at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 768, 1280]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters"); await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } }); test("has no critical or serious axe violations on detail at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 768, 1280]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters/101"); await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible(); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } }); test("has no critical or serious axe violations on the edit dirty-leave dialog at 768 and 1280px with 200 percent zoom", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [768, 1280]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters/101/edit"); await expect(page.getByRole("heading", { name: "AI 캐릭터 수정" })).toBeVisible(); await page.getByLabel("이름").fill("루나 dirty"); await page.getByRole("button", { name: "상세로 돌아가기" }).click(); await expect(page.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" })).toBeVisible(); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } });